phronesis 1.0.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 (142) hide show
  1. package/README.md +66 -0
  2. package/package.json +34 -0
  3. package/src/act.js +61 -0
  4. package/src/active-context.js +56 -0
  5. package/src/adapter.js +386 -0
  6. package/src/cli.js +1893 -0
  7. package/src/codex-hooks-schema.js +92 -0
  8. package/src/compile.js +513 -0
  9. package/src/doctor.js +303 -0
  10. package/src/due.js +271 -0
  11. package/src/eval.js +467 -0
  12. package/src/event.js +212 -0
  13. package/src/export.js +136 -0
  14. package/src/guard.js +89 -0
  15. package/src/hooks.js +230 -0
  16. package/src/ingest.js +965 -0
  17. package/src/init.js +150 -0
  18. package/src/layout.js +394 -0
  19. package/src/links.js +83 -0
  20. package/src/lint.js +58 -0
  21. package/src/profile.js +116 -0
  22. package/src/prompts.js +26 -0
  23. package/src/registry.js +190 -0
  24. package/src/scaffold.js +134 -0
  25. package/src/search.js +139 -0
  26. package/src/skill-discovery.js +117 -0
  27. package/src/skills-bump.js +885 -0
  28. package/src/skills.js +355 -0
  29. package/src/subscription.js +5 -0
  30. package/src/validate.js +152 -0
  31. package/templates/claude/settings.json +86 -0
  32. package/templates/codex/INDEX.md +68 -0
  33. package/templates/codex/seed/ai-practice/compile-dont-just-capture.md +42 -0
  34. package/templates/codex/seed/ai-practice/design-for-the-distribution.md +42 -0
  35. package/templates/codex/seed/ai-practice/from-spec-author-to-outcome-curator.md +43 -0
  36. package/templates/codex/seed/ai-practice/recall-builds-trust-when-calibrated.md +70 -0
  37. package/templates/codex/seed/ai-practice/the-eval-is-the-spec.md +42 -0
  38. package/templates/codex/seed/ai-practice/the-gold-dust-leaves-no-trace.md +72 -0
  39. package/templates/codex/seed/ai-practice/the-model-is-not-the-moat.md +40 -0
  40. package/templates/codex/seed/ai-practice/when-generation-is-cheap-judgment-is-the-work.md +39 -0
  41. package/templates/codex/seed/judgment/contextual-stewardship-is-the-senior-skill.md +50 -0
  42. package/templates/codex/seed/judgment/decision-quality-is-distinct-from-outcome-quality.md +39 -0
  43. package/templates/codex/seed/judgment/embrace-reality-ego-and-blind-spots.md +38 -0
  44. package/templates/codex/seed/judgment/groups-seek-consensus-individuals-seek-truth.md +44 -0
  45. package/templates/codex/seed/judgment/heresies-are-where-the-edge-lives.md +42 -0
  46. package/templates/codex/seed/judgment/judgment-compounds-through-honest-reflection.md +38 -0
  47. package/templates/codex/seed/judgment/phronesis-is-judgment-in-the-particular.md +67 -0
  48. package/templates/codex/seed/judgment/show-me-the-apparatus.md +40 -0
  49. package/templates/codex/seed/judgment/small-judgment-gaps-compound-under-leverage.md +37 -0
  50. package/templates/codex/seed/judgment/taste-is-compressed-judgment.md +38 -0
  51. package/templates/codex/seed/judgment/trust-taste-only-where-feedback-is-valid.md +42 -0
  52. package/templates/codex/seed/judgment/weigh-the-reasoning-not-the-recommendation.md +43 -0
  53. package/templates/codex/seed/leadership/a-managers-output-is-the-teams-output.md +35 -0
  54. package/templates/codex/seed/leadership/most-team-problems-are-structural.md +40 -0
  55. package/templates/codex/seed/leadership/next-play.md +36 -0
  56. package/templates/codex/seed/leadership/psychological-safety.md +39 -0
  57. package/templates/codex/seed/leadership/right-size-your-teams.md +38 -0
  58. package/templates/codex/seed/leadership/spend-time-on-high-leverage-activities.md +36 -0
  59. package/templates/codex/seed/leadership/talent-density-is-the-master-variable.md +39 -0
  60. package/templates/codex/seed/leadership/under-pressure-calm-is-contagious.md +36 -0
  61. package/templates/codex/seed/leadership/your-best-alternative-is-your-leverage.md +37 -0
  62. package/templates/codex/seed/pm/a-problem-well-stated-is-half-solved.md +39 -0
  63. package/templates/codex/seed/pm/a-real-strategy-makes-planning-trivial.md +40 -0
  64. package/templates/codex/seed/pm/barrels-and-ammunition.md +39 -0
  65. package/templates/codex/seed/pm/develop-taste-in-your-beliefs.md +38 -0
  66. package/templates/codex/seed/pm/hire-for-progress.md +44 -0
  67. package/templates/codex/seed/pm/jobs-are-stable-solutions-churn.md +39 -0
  68. package/templates/codex/seed/pm/level-by-the-ambiguity-someone-can-own.md +39 -0
  69. package/templates/codex/seed/pm/listen-before-you-prescribe.md +38 -0
  70. package/templates/codex/seed/pm/live-in-the-future-and-build-whats-missing.md +38 -0
  71. package/templates/codex/seed/pm/match-effort-to-leverage.md +37 -0
  72. package/templates/codex/seed/pm/not-my-fault-but-my-problem.md +40 -0
  73. package/templates/codex/seed/pm/pick-the-job-then-refuse-everything-outside-it.md +37 -0
  74. package/templates/codex/seed/pm/retention-sets-the-ceiling.md +38 -0
  75. package/templates/codex/seed/pm/scale-with-leaders-not-process.md +38 -0
  76. package/templates/codex/seed/pm/solution-discovery-over-problem-validation.md +38 -0
  77. package/templates/codex/seed/pm/tenfold-better-on-a-painful-problem.md +40 -0
  78. package/templates/codex-surface/README.md +73 -0
  79. package/templates/codex-surface/hooks/_resolve.sh +36 -0
  80. package/templates/codex-surface/hooks/recall-guard.sh +30 -0
  81. package/templates/codex-surface/hooks/session-start.sh +18 -0
  82. package/templates/codex-surface/hooks/session-sweep-precompact.sh +20 -0
  83. package/templates/codex-surface/hooks/session-sweep-subagent.sh +23 -0
  84. package/templates/codex-surface/hooks/session-sweep.sh +19 -0
  85. package/templates/codex-surface/hooks.json +69 -0
  86. package/templates/domains/pm/AGENTS.md +111 -0
  87. package/templates/domains/pm/MEMORY.md +7 -0
  88. package/templates/domains/pm/USER.md +8 -0
  89. package/templates/phronesis-hooks/compile-active-context.sh +38 -0
  90. package/templates/phronesis-hooks/recall-guard.sh +103 -0
  91. package/templates/phronesis-hooks/session-start.sh +90 -0
  92. package/templates/phronesis-hooks/session-sweep.sh +140 -0
  93. package/templates/phronesis-hooks/skill-lifecycle.sh +37 -0
  94. package/templates/skills/compile/SKILL.md +279 -0
  95. package/templates/skills/compile/evals/rubric.md +75 -0
  96. package/templates/skills/decision-log/SKILL.md +156 -0
  97. package/templates/skills/decision-log/evals/rubric.md +97 -0
  98. package/templates/skills/extract-gold-dust/SKILL.md +311 -0
  99. package/templates/skills/extract-gold-dust/evals/rubric.md +52 -0
  100. package/templates/skills/ingest/SKILL.md +204 -0
  101. package/templates/skills/ingest/evals/rubric.md +117 -0
  102. package/templates/skills/lint/SKILL.md +246 -0
  103. package/templates/skills/lint/evals/rubric.md +139 -0
  104. package/templates/skills/meeting-prep/SKILL.md +195 -0
  105. package/templates/skills/meeting-prep/evals/rubric.md +93 -0
  106. package/templates/skills/onboard/SKILL.md +258 -0
  107. package/templates/skills/onboard/evals/rubric.md +60 -0
  108. package/templates/skills/prd-draft/SKILL.md +193 -0
  109. package/templates/skills/prd-draft/evals/rubric.md +38 -0
  110. package/templates/skills/prep-extraction/SKILL.md +217 -0
  111. package/templates/skills/prep-extraction/evals/rubric.md +45 -0
  112. package/templates/skills/recall/SKILL.md +155 -0
  113. package/templates/skills/recall/evals/rubric.md +37 -0
  114. package/templates/skills/research/SKILL.md +247 -0
  115. package/templates/skills/research/evals/rubric.md +139 -0
  116. package/templates/skills/stakeholder-update/SKILL.md +163 -0
  117. package/templates/skills/stakeholder-update/evals/rubric.md +111 -0
  118. package/vendor/core/package.json +25 -0
  119. package/vendor/core/src/action-registry.js +459 -0
  120. package/vendor/core/src/action-writers.js +201 -0
  121. package/vendor/core/src/actions.js +2140 -0
  122. package/vendor/core/src/active-context.js +167 -0
  123. package/vendor/core/src/artifacts.js +301 -0
  124. package/vendor/core/src/content-digest.js +21 -0
  125. package/vendor/core/src/contract.js +245 -0
  126. package/vendor/core/src/event-log.js +338 -0
  127. package/vendor/core/src/guard.js +398 -0
  128. package/vendor/core/src/hook-executor.js +527 -0
  129. package/vendor/core/src/index.js +180 -0
  130. package/vendor/core/src/ingest.js +286 -0
  131. package/vendor/core/src/install-root.js +42 -0
  132. package/vendor/core/src/layout.js +13 -0
  133. package/vendor/core/src/link-index.js +198 -0
  134. package/vendor/core/src/lint.js +368 -0
  135. package/vendor/core/src/schedule.js +535 -0
  136. package/vendor/core/src/search.js +534 -0
  137. package/vendor/core/src/session-hooks.js +129 -0
  138. package/vendor/core/src/skill-lifecycle.js +479 -0
  139. package/vendor/core/src/skill-registry.js +524 -0
  140. package/vendor/core/src/subscription.js +84 -0
  141. package/vendor/core/src/validate-object.js +62 -0
  142. package/vendor/core/src/workspace-scan.js +125 -0
package/src/ingest.js ADDED
@@ -0,0 +1,965 @@
1
+ import { createHash } from 'node:crypto';
2
+ import { lookup as dnsLookup } from 'node:dns/promises';
3
+ import { constants as fsConstants } from 'node:fs';
4
+ import { request as httpRequest } from 'node:http';
5
+ import { request as httpsRequest } from 'node:https';
6
+ import { isIP } from 'node:net';
7
+ import { TextDecoder } from 'node:util';
8
+ import { basename, extname, isAbsolute, join, relative, resolve, sep } from 'node:path';
9
+ import { open, readdir, realpath, stat } from 'node:fs/promises';
10
+ import { brotliDecompressSync, gunzipSync, inflateRawSync, inflateSync } from 'node:zlib';
11
+ import { assertRawDestination, resolveInstall, stageRawSource, slugify } from '@phronesis/core';
12
+
13
+ const TRUST_TIERS = ['trusted', 'untrusted'];
14
+ const REDIRECT_STATUSES = new Set([301, 302, 303, 307, 308]);
15
+ const FETCH_TIMEOUT_MS = 15_000;
16
+ const MAX_RESPONSE_BYTES = 10 * 1024 * 1024;
17
+ const MAX_LOCAL_FILE_BYTES = 10 * 1024 * 1024;
18
+ const NOFOLLOW_OPEN_FLAGS =
19
+ typeof fsConstants.O_NOFOLLOW === 'number' ? fsConstants.O_RDONLY | fsConstants.O_NOFOLLOW : fsConstants.O_RDONLY;
20
+ const STRUCTURAL_HTML_TAG_RE =
21
+ /<!doctype\b|<\/?\s*(html|head|body|main|article|section|header|footer|nav|aside|div|p|br|h[1-6]|ul|ol|li|blockquote|pre|table|thead|tbody|tfoot|tr|td|th|script|style|noscript|title|meta|link|img|a|span|strong|em)\b[^>]*>/i;
22
+
23
+ function sha256Short(input, length = 16) {
24
+ return createHash('sha256').update(input).digest('hex').slice(0, length);
25
+ }
26
+
27
+ function sanitizeUrlForProvenance(raw) {
28
+ const url = new URL(raw);
29
+ url.search = '';
30
+ url.hash = '';
31
+ return url.toString();
32
+ }
33
+
34
+ function isHttpUrl(value) {
35
+ try {
36
+ const url = new URL(value);
37
+ return url.protocol === 'http:' || url.protocol === 'https:';
38
+ } catch {
39
+ return false;
40
+ }
41
+ }
42
+
43
+ function isExplicitUrl(value) {
44
+ return /^[a-z][a-z0-9+.-]*:\/\//i.test(String(value || ''));
45
+ }
46
+
47
+ function htmlEntityDecode(text) {
48
+ const named = {
49
+ amp: '&',
50
+ apos: "'",
51
+ gt: '>',
52
+ lt: '<',
53
+ nbsp: ' ',
54
+ quot: '"',
55
+ };
56
+ return text.replace(/&(#x[0-9a-f]+|#\d+|[a-z]+);/gi, (m, entity) => {
57
+ const key = entity.toLowerCase();
58
+ if (Object.hasOwn(named, key)) return named[key];
59
+ const codePoint = key.startsWith('#x') ? parseInt(key.slice(2), 16) : key.startsWith('#') ? parseInt(key.slice(1), 10) : NaN;
60
+ if (Number.isInteger(codePoint) && codePoint >= 0 && codePoint <= 0x10ffff) return String.fromCodePoint(codePoint);
61
+ return m;
62
+ });
63
+ }
64
+
65
+ function stripRawTextElement(text, tagName) {
66
+ const openRe = new RegExp(`<\\s*${tagName}\\b[^>]*>`, 'gi');
67
+ const closeRe = new RegExp(`<\\/\\s*${tagName}\\s*>`, 'gi');
68
+ const nextTagRe = /<[^>]+>/g;
69
+ let output = '';
70
+ let cursor = 0;
71
+
72
+ while (cursor < text.length) {
73
+ openRe.lastIndex = cursor;
74
+ const open = openRe.exec(text);
75
+ if (!open) return output + text.slice(cursor);
76
+ output += text.slice(cursor, open.index);
77
+
78
+ closeRe.lastIndex = openRe.lastIndex;
79
+ const close = closeRe.exec(text);
80
+ if (!close) return output;
81
+ cursor = closeRe.lastIndex;
82
+
83
+ while (cursor < text.length) {
84
+ nextTagRe.lastIndex = cursor;
85
+ const nextTag = nextTagRe.exec(text);
86
+ if (!nextTag) break;
87
+ closeRe.lastIndex = cursor;
88
+ const nextClose = closeRe.exec(text);
89
+ if (!nextClose || nextClose.index !== nextTag.index) break;
90
+ cursor = closeRe.lastIndex;
91
+ }
92
+ }
93
+ return output;
94
+ }
95
+
96
+ function stripDangerousHtmlBlocks(text) {
97
+ let stripped = text;
98
+ for (const tagName of ['script', 'style', 'noscript']) {
99
+ stripped = stripRawTextElement(stripped, tagName);
100
+ }
101
+ return stripped;
102
+ }
103
+
104
+ export function stripHtmlToMarkdown(html) {
105
+ const withoutRawBlocks = stripDangerousHtmlBlocks(String(html));
106
+ const decoded = stripDangerousHtmlBlocks(htmlEntityDecode(withoutRawBlocks));
107
+ return decoded
108
+ .replace(/<!--[\s\S]*?-->/g, '')
109
+ .replace(/<!doctype[^>]*>/gi, '')
110
+ .replace(/<h1\b[^>]*>([\s\S]*?)<\/h1>/gi, '\n\n# $1\n\n')
111
+ .replace(/<h2\b[^>]*>([\s\S]*?)<\/h2>/gi, '\n\n## $1\n\n')
112
+ .replace(/<h3\b[^>]*>([\s\S]*?)<\/h3>/gi, '\n\n### $1\n\n')
113
+ .replace(/<li\b[^>]*>/gi, '\n- ')
114
+ .replace(/<\/(p|div|section|article|header|footer|main|li|ul|ol|blockquote|pre|tr)>/gi, '\n')
115
+ .replace(/<br\s*\/?>/gi, '\n')
116
+ .replace(/<[^>]+>/g, '')
117
+ .replace(/[<>]/g, (char) => (char === '<' ? '&lt;' : '&gt;'))
118
+ .replace(/[ \t]+\n/g, '\n')
119
+ .replace(/\n{3,}/g, '\n\n')
120
+ .trim();
121
+ }
122
+
123
+ function firstMeaningfulLine(body) {
124
+ const line = body.split(/\r?\n/).find((l) => l.trim());
125
+ return line ? line.replace(/^#+\s*/, '').trim() : '';
126
+ }
127
+
128
+ function localFileSlug(file) {
129
+ return slugify(basename(file, extname(file))) || 'local-file';
130
+ }
131
+
132
+ function urlSlug(raw) {
133
+ const url = new URL(raw);
134
+ const last = url.pathname.split('/').filter(Boolean).pop();
135
+ return slugify(last ? last.replace(/\.[A-Za-z0-9]+$/, '') : url.hostname) || 'url-source';
136
+ }
137
+
138
+ function looksLikeHtml({ body, contentType = '' }) {
139
+ return /\bhtml\b/i.test(contentType) || STRUCTURAL_HTML_TAG_RE.test(body);
140
+ }
141
+
142
+ function readStream(stream) {
143
+ return new Promise((resolvePromise, reject) => {
144
+ let data = '';
145
+ stream.setEncoding('utf8');
146
+ stream.on('data', (chunk) => {
147
+ data += chunk;
148
+ });
149
+ stream.on('end', () => resolvePromise(data));
150
+ stream.on('error', reject);
151
+ });
152
+ }
153
+
154
+ function privateIpv4(address) {
155
+ const parts = address.split('.').map((p) => Number(p));
156
+ if (parts.length !== 4 || parts.some((p) => !Number.isInteger(p) || p < 0 || p > 255)) return true;
157
+ const [a, b, c, d] = parts;
158
+ return (
159
+ a === 0 ||
160
+ a === 10 ||
161
+ a === 127 ||
162
+ (a === 169 && b === 254) ||
163
+ (a === 172 && b >= 16 && b <= 31) ||
164
+ (a === 192 && b === 0 && c === 0) ||
165
+ (a === 192 && b === 168) ||
166
+ (a === 198 && (b === 18 || b === 19)) ||
167
+ (a === 100 && b >= 64 && b <= 127) ||
168
+ (a === 192 && b === 0 && c === 2) ||
169
+ (a === 198 && b === 51 && c === 100) ||
170
+ (a === 203 && b === 0 && c === 113) ||
171
+ (a >= 224 && a <= 255) ||
172
+ (a === 255 && b === 255 && c === 255 && d === 255)
173
+ );
174
+ }
175
+
176
+ function mappedIpv4FromIpv6(address) {
177
+ if (!address.startsWith('::ffff:')) return null;
178
+ const tail = address.slice('::ffff:'.length);
179
+ if (isIP(tail) === 4) return tail;
180
+ const pieces = tail.split(':');
181
+ if (pieces.length !== 2) return null;
182
+ const words = pieces.map((piece) => parseInt(piece, 16));
183
+ if (words.some((word) => !Number.isInteger(word) || word < 0 || word > 0xffff)) return null;
184
+ return `${words[0] >> 8}.${words[0] & 0xff}.${words[1] >> 8}.${words[1] & 0xff}`;
185
+ }
186
+
187
+ function embeddedMappedIpv4(hextets) {
188
+ if (
189
+ hextets?.length === 8 &&
190
+ hextets[0] === 0 &&
191
+ hextets[1] === 0 &&
192
+ hextets[2] === 0 &&
193
+ hextets[3] === 0 &&
194
+ hextets[4] === 0 &&
195
+ hextets[5] === 0xffff
196
+ ) {
197
+ return `${hextets[6] >> 8}.${hextets[6] & 0xff}.${hextets[7] >> 8}.${hextets[7] & 0xff}`;
198
+ }
199
+ return null;
200
+ }
201
+
202
+ function embeddedCompatibleIpv4(hextets) {
203
+ if (
204
+ hextets?.length === 8 &&
205
+ hextets[0] === 0 &&
206
+ hextets[1] === 0 &&
207
+ hextets[2] === 0 &&
208
+ hextets[3] === 0 &&
209
+ hextets[4] === 0 &&
210
+ hextets[5] === 0
211
+ ) {
212
+ return `${hextets[6] >> 8}.${hextets[6] & 0xff}.${hextets[7] >> 8}.${hextets[7] & 0xff}`;
213
+ }
214
+ return null;
215
+ }
216
+
217
+ function ipv6Hextets(address) {
218
+ let input = address;
219
+ if (input.includes('.')) {
220
+ const lastColon = input.lastIndexOf(':');
221
+ const ipv4 = input.slice(lastColon + 1);
222
+ if (isIP(ipv4) !== 4) return null;
223
+ const octets = ipv4.split('.').map((part) => Number(part));
224
+ const ipv4Words = [(octets[0] << 8) | octets[1], (octets[2] << 8) | octets[3]];
225
+ input = `${input.slice(0, lastColon)}:${ipv4Words.map((word) => word.toString(16)).join(':')}`;
226
+ }
227
+ const halves = input.split('::');
228
+ if (halves.length > 2) return null;
229
+ const left = halves[0] ? halves[0].split(':') : [];
230
+ const right = halves.length === 2 && halves[1] ? halves[1].split(':') : [];
231
+ const missing = 8 - left.length - right.length;
232
+ if (missing < 0 || (halves.length === 1 && missing !== 0)) return null;
233
+ const parts = halves.length === 2 ? [...left, ...Array(missing).fill('0'), ...right] : [...left, ...right];
234
+ if (parts.length !== 8) return null;
235
+ const parsed = parts.map((part) => parseInt(part || '0', 16));
236
+ if (parsed.some((part) => !Number.isInteger(part) || part < 0 || part > 0xffff)) return null;
237
+ return parsed;
238
+ }
239
+
240
+ function embeddedNat64Ipv4(hextets) {
241
+ if (
242
+ hextets?.length === 8 &&
243
+ hextets[0] === 0x0064 &&
244
+ hextets[1] === 0xff9b &&
245
+ hextets[2] === 0 &&
246
+ hextets[3] === 0 &&
247
+ hextets[4] === 0 &&
248
+ hextets[5] === 0
249
+ ) {
250
+ return `${hextets[6] >> 8}.${hextets[6] & 0xff}.${hextets[7] >> 8}.${hextets[7] & 0xff}`;
251
+ }
252
+ return null;
253
+ }
254
+
255
+ function privateIp(address) {
256
+ const normalized = address.replace(/^\[|\]$/g, '').replace(/%.*$/, '').toLowerCase();
257
+ const mappedIpv4 = mappedIpv4FromIpv6(normalized);
258
+ if (mappedIpv4) return privateIpv4(mappedIpv4);
259
+ if (isIP(normalized) === 4) return privateIpv4(normalized);
260
+ if (isIP(normalized) === 6) {
261
+ const hextets = ipv6Hextets(normalized);
262
+ const expandedMappedIpv4 = embeddedMappedIpv4(hextets);
263
+ if (expandedMappedIpv4) return privateIpv4(expandedMappedIpv4);
264
+ const compatibleIpv4 = embeddedCompatibleIpv4(hextets);
265
+ if (compatibleIpv4) return privateIpv4(compatibleIpv4);
266
+ const nat64Ipv4 = embeddedNat64Ipv4(hextets);
267
+ if (nat64Ipv4) return privateIpv4(nat64Ipv4);
268
+ const firstHextet = hextets?.[0] ?? parseInt(normalized.split(':').find((part) => part.length > 0) || '0', 16);
269
+ return (
270
+ normalized === '::' ||
271
+ normalized === '::1' ||
272
+ (firstHextet & 0xfe00) === 0xfc00 ||
273
+ (firstHextet & 0xffc0) === 0xfe80 ||
274
+ (firstHextet & 0xffc0) === 0xfec0 ||
275
+ (firstHextet & 0xff00) === 0xff00 ||
276
+ (firstHextet === 0x2001 && hextets?.[1] === 0x0db8)
277
+ );
278
+ }
279
+ return false;
280
+ }
281
+
282
+ async function resolveFetchablePublicUrl(url, lookupImpl, { allowPrivateNetworkForTest = false } = {}) {
283
+ if (url.protocol !== 'http:' && url.protocol !== 'https:') {
284
+ throw new Error(`URL protocol "${url.protocol}" is not supported (expected http or https).`);
285
+ }
286
+ const host = url.hostname.replace(/^\[|\]$/g, '').toLowerCase();
287
+ if (!allowPrivateNetworkForTest && (host === 'localhost' || host.endsWith('.localhost') || privateIp(host))) {
288
+ throw new Error(`refusing to fetch internal or loopback URL host "${url.hostname}".`);
289
+ }
290
+ let addresses;
291
+ try {
292
+ addresses = await lookupImpl(url.hostname, { all: true, verbatim: true });
293
+ } catch (error) {
294
+ throw new Error(`could not resolve URL host "${url.hostname}": ${error.message}`);
295
+ }
296
+ if (!Array.isArray(addresses) || addresses.length === 0) {
297
+ throw new Error(`could not resolve URL host "${url.hostname}" to an address.`);
298
+ }
299
+ for (const entry of addresses) {
300
+ if (!allowPrivateNetworkForTest && privateIp(entry.address)) {
301
+ throw new Error(`refusing to fetch URL host "${url.hostname}" because it resolves to internal address ${entry.address}.`);
302
+ }
303
+ }
304
+ return addresses[0];
305
+ }
306
+
307
+ function responseHeader(headers, name) {
308
+ const value = headers?.[name.toLowerCase()];
309
+ if (Array.isArray(value)) return value.join(', ');
310
+ return value ?? null;
311
+ }
312
+
313
+ function declaredContentLength(headers) {
314
+ const raw = responseHeader(headers, 'content-length');
315
+ if (!raw) return null;
316
+ const parts = String(raw)
317
+ .split(',')
318
+ .map((part) => part.trim())
319
+ .filter(Boolean);
320
+ if (parts.length === 0) return null;
321
+ if (!parts.every((part) => /^\d+$/.test(part))) {
322
+ throw new Error(`URL fetch returned malformed Content-Length "${raw}".`);
323
+ }
324
+ if (!parts.every((part) => part === parts[0])) {
325
+ throw new Error(`URL fetch returned conflicting Content-Length "${raw}".`);
326
+ }
327
+ return Number(parts[0]);
328
+ }
329
+
330
+ function contentEncodingChain(headers) {
331
+ const raw = responseHeader(headers, 'content-encoding');
332
+ return raw
333
+ ? raw
334
+ .split(',')
335
+ .map((part) => part.trim().toLowerCase())
336
+ .filter(Boolean)
337
+ : [];
338
+ }
339
+
340
+ function inflateDeflate(buffer, maxBytes) {
341
+ try {
342
+ return inflateSync(buffer, { maxOutputLength: maxBytes });
343
+ } catch (error) {
344
+ if (error?.code !== 'Z_DATA_ERROR') throw error;
345
+ return inflateRawSync(buffer, { maxOutputLength: maxBytes });
346
+ }
347
+ }
348
+
349
+ function decompressBody(buffer, encodings, maxBytes) {
350
+ let decoded = buffer;
351
+ for (const encoding of [...encodings].reverse()) {
352
+ if (encoding === 'identity') continue;
353
+ try {
354
+ if (encoding === 'gzip' || encoding === 'x-gzip') decoded = gunzipSync(decoded, { maxOutputLength: maxBytes });
355
+ else if (encoding === 'deflate') decoded = inflateDeflate(decoded, maxBytes);
356
+ else if (encoding === 'br') decoded = brotliDecompressSync(decoded, { maxOutputLength: maxBytes });
357
+ else throw new Error(`URL fetch returned unsupported content-encoding "${encoding}".`);
358
+ } catch (error) {
359
+ if (error?.code === 'ERR_BUFFER_TOO_LARGE') {
360
+ throw new Error(`URL fetch decoded response exceeded ${maxBytes} bytes.`);
361
+ }
362
+ throw error;
363
+ }
364
+ if (decoded.length > maxBytes) throw new Error(`URL fetch decoded response exceeded ${maxBytes} bytes.`);
365
+ }
366
+ return decoded;
367
+ }
368
+
369
+ function responseCharset(headers) {
370
+ const contentType = responseHeader(headers, 'content-type') || '';
371
+ const match = contentType.match(/;\s*charset=([^;]+)/i);
372
+ const charset = (match?.[1] || 'utf-8').trim().replace(/^"|"$/g, '').toLowerCase();
373
+ if (charset === 'utf8') return 'utf-8';
374
+ if (charset === 'latin1') return 'iso-8859-1';
375
+ return charset;
376
+ }
377
+
378
+ function decodeBody(buffer, headers, maxBytes) {
379
+ const decompressed = decompressBody(buffer, contentEncodingChain(headers), maxBytes);
380
+ const charset = responseCharset(headers);
381
+ try {
382
+ return new TextDecoder(charset, { fatal: false }).decode(decompressed);
383
+ } catch (error) {
384
+ throw new Error(`URL fetch returned unsupported charset "${charset}": ${error.message}`);
385
+ }
386
+ }
387
+
388
+ function toReportPath(path) {
389
+ return String(path).split(sep).join('/');
390
+ }
391
+
392
+ function sortedDirents(entries) {
393
+ return [...entries].sort((a, b) => {
394
+ if (a.name < b.name) return -1;
395
+ if (a.name > b.name) return 1;
396
+ return 0;
397
+ });
398
+ }
399
+
400
+ function reportRecord(path, reason) {
401
+ return { path: toReportPath(path), reason };
402
+ }
403
+
404
+ function addSkip(report, path, reason) {
405
+ report.skipped.push(reportRecord(path, reason));
406
+ }
407
+
408
+ function addFailure(report, path, reason) {
409
+ report.failed.push(reportRecord(path, reason));
410
+ }
411
+
412
+ function isSameOrInside(parent, child) {
413
+ const rel = relative(parent, child);
414
+ return rel === '' || (rel !== '..' && !rel.startsWith(`..${sep}`) && !isAbsolute(rel));
415
+ }
416
+
417
+ async function realpathOrResolve(path) {
418
+ try {
419
+ return await realpath(path);
420
+ } catch {
421
+ return resolve(path);
422
+ }
423
+ }
424
+
425
+ async function isPhronesisInstallRoot(dir) {
426
+ try {
427
+ const marker = await stat(join(dir, '.phronesis', 'registry.json'));
428
+ return marker.isFile();
429
+ } catch {
430
+ return false;
431
+ }
432
+ }
433
+
434
+ function addDeduped(report, path, existingPath) {
435
+ report.deduped += 1;
436
+ report.deduped_items.push({ path: toReportPath(path), existing_path: toReportPath(existingPath) });
437
+ }
438
+
439
+ function hasConnectorBridgeInput({ sourceConnector, sourceExternalId, sourceTimestamp }) {
440
+ return sourceConnector !== undefined || sourceExternalId !== undefined || sourceTimestamp !== undefined;
441
+ }
442
+
443
+ function requireNonEmptyOption(value, flag) {
444
+ if (typeof value !== 'string' || value.trim() === '') throw new Error(`connector bridge ingest requires ${flag} <value>.`);
445
+ }
446
+
447
+ function connectorBridgeProvenance({ sourceConnector, sourceExternalId, sourceTimestamp }) {
448
+ requireNonEmptyOption(sourceConnector, '--source-connector');
449
+ requireNonEmptyOption(sourceExternalId, '--source-external-id');
450
+ requireNonEmptyOption(sourceTimestamp, '--source-timestamp');
451
+ return {
452
+ source_connector: sourceConnector,
453
+ source_external_id: sourceExternalId,
454
+ source_timestamp: sourceTimestamp,
455
+ };
456
+ }
457
+
458
+ function slugFromTitle({ title, slug }) {
459
+ if (typeof slug === 'string' && slug.trim()) return slug.trim();
460
+ return slugify(title);
461
+ }
462
+
463
+ function singleSourceReportPath(options) {
464
+ if (options.paste || options.source === '-') return '-';
465
+ return options.source || 'source';
466
+ }
467
+
468
+ function decodeStrictUtf8(buffer) {
469
+ if (buffer.includes(0)) return { error: 'binary' };
470
+ try {
471
+ return { raw: new TextDecoder('utf-8', { fatal: true }).decode(buffer) };
472
+ } catch {
473
+ return { error: 'invalid-utf8' };
474
+ }
475
+ }
476
+
477
+ async function readLocalFileCapped(absolute, maxBytes, { followSymlinks = true } = {}) {
478
+ const flags = followSymlinks ? 'r' : NOFOLLOW_OPEN_FLAGS;
479
+ const handle = await open(absolute, flags);
480
+ const chunks = [];
481
+ let total = 0;
482
+ try {
483
+ while (true) {
484
+ const remainingBeforeProbe = maxBytes + 1 - total;
485
+ const buffer = Buffer.alloc(Math.min(64 * 1024, Math.max(1, remainingBeforeProbe)));
486
+ const { bytesRead } = await handle.read(buffer, 0, buffer.length, null);
487
+ if (bytesRead === 0) break;
488
+ total += bytesRead;
489
+ if (total > maxBytes) {
490
+ return { skipped: `oversized: more than ${maxBytes} bytes` };
491
+ }
492
+ chunks.push(buffer.subarray(0, bytesRead));
493
+ }
494
+ } finally {
495
+ await handle.close().catch(() => {});
496
+ }
497
+ return { buffer: Buffer.concat(chunks, total) };
498
+ }
499
+
500
+ async function collectBulkFiles({ root, dir = root, report, excludedRoot }) {
501
+ const resolvedDir = await realpathOrResolve(dir);
502
+ if ((excludedRoot && isSameOrInside(excludedRoot, resolvedDir)) || (await isPhronesisInstallRoot(dir))) {
503
+ addSkip(report, relative(root, dir) || '.', 'install-root');
504
+ return [];
505
+ }
506
+
507
+ let entries;
508
+ try {
509
+ entries = await readdir(dir, { withFileTypes: true });
510
+ } catch (error) {
511
+ addSkip(report, relative(root, dir) || '.', `unreadable: ${error.message}`);
512
+ return [];
513
+ }
514
+
515
+ const files = [];
516
+ for (const entry of sortedDirents(entries)) {
517
+ const absolute = join(dir, entry.name);
518
+ const sourcePath = relative(root, absolute);
519
+ if (entry.name.startsWith('.')) {
520
+ addSkip(report, sourcePath, 'hidden');
521
+ continue;
522
+ }
523
+ if (entry.isSymbolicLink()) {
524
+ addSkip(report, sourcePath, 'non-regular');
525
+ continue;
526
+ }
527
+ if (entry.isDirectory()) {
528
+ files.push(...(await collectBulkFiles({ root, dir: absolute, report, excludedRoot })));
529
+ continue;
530
+ }
531
+ if (!entry.isFile()) {
532
+ addSkip(report, sourcePath, 'non-regular');
533
+ continue;
534
+ }
535
+ files.push({ absolute, sourcePath: toReportPath(sourcePath) });
536
+ }
537
+ return files;
538
+ }
539
+
540
+ function prepareLocalFileSource({ absolute, info, raw, trustTier, now, sourcePath }) {
541
+ const fileBase = basename(absolute);
542
+ const isHtml = ['.html', '.htm'].includes(extname(fileBase).toLowerCase()) || looksLikeHtml({ body: raw });
543
+ const body = isHtml ? stripHtmlToMarkdown(raw) : raw;
544
+ if (body.trim() === '') throw new Error('empty');
545
+ return {
546
+ body,
547
+ slug: localFileSlug(fileBase),
548
+ provenance: {
549
+ source_connector: 'manual',
550
+ // Basename-only parity with single-file ingest is the dedup contract; source_path is traceability, not identity.
551
+ source_external_id: `file:${fileBase}:${sha256Short(raw)}`,
552
+ source_timestamp: info.mtime.toISOString(),
553
+ source_file: fileBase,
554
+ source_path: sourcePath,
555
+ content_type: 'file',
556
+ retrieval_tool: 'local-file',
557
+ trust_tier: trustTier,
558
+ operator_affirmed_trusted: trustTier === 'trusted' ? true : undefined,
559
+ fetched_at: now.toISOString(),
560
+ },
561
+ };
562
+ }
563
+
564
+ async function sourceFromBulkFile({ absolute, sourcePath, trustTier, now, maxLocalFileBytes }) {
565
+ let info;
566
+ try {
567
+ info = await stat(absolute);
568
+ } catch (error) {
569
+ return { skipped: `unreadable: ${error.message}` };
570
+ }
571
+ if (!info.isFile()) return { skipped: 'non-regular' };
572
+ if (info.size > maxLocalFileBytes) return { skipped: `oversized: ${info.size} bytes exceeds ${maxLocalFileBytes}` };
573
+
574
+ let read;
575
+ try {
576
+ read = await readLocalFileCapped(absolute, maxLocalFileBytes, { followSymlinks: false });
577
+ } catch (error) {
578
+ return { skipped: `unreadable: ${error.message}` };
579
+ }
580
+ if (read.skipped) return { skipped: read.skipped };
581
+ const buffer = read.buffer;
582
+
583
+ const decoded = decodeStrictUtf8(buffer);
584
+ if (decoded.error) return { skipped: decoded.error };
585
+
586
+ try {
587
+ return {
588
+ source: prepareLocalFileSource({
589
+ absolute,
590
+ info,
591
+ raw: decoded.raw,
592
+ trustTier,
593
+ now,
594
+ sourcePath,
595
+ }),
596
+ };
597
+ } catch (error) {
598
+ return { skipped: error.message || 'invalid' };
599
+ }
600
+ }
601
+
602
+ async function ingestDirectory({ absolute, installRoot, domain, lane, trustTier, now, maxLocalFileBytes = MAX_LOCAL_FILE_BYTES }) {
603
+ await assertRawDestination({ installRoot, domain, lane });
604
+
605
+ const report = { staged: 0, deduped: 0, deduped_items: [], skipped: [], failed: [] };
606
+ const files = (await collectBulkFiles({ root: absolute, report, excludedRoot: await realpathOrResolve(installRoot) })).sort((a, b) => {
607
+ if (a.sourcePath < b.sourcePath) return -1;
608
+ if (a.sourcePath > b.sourcePath) return 1;
609
+ return 0;
610
+ });
611
+ // The flattened sort makes the retained source_path deterministic when same-basename/same-byte copies dedupe.
612
+ for (const file of files) {
613
+ const prepared = await sourceFromBulkFile({
614
+ absolute: file.absolute,
615
+ sourcePath: file.sourcePath,
616
+ trustTier,
617
+ now,
618
+ maxLocalFileBytes,
619
+ });
620
+ if (prepared.skipped) {
621
+ addSkip(report, file.sourcePath, prepared.skipped);
622
+ continue;
623
+ }
624
+ try {
625
+ const result = await stageRawSource({
626
+ installRoot,
627
+ domain,
628
+ lane,
629
+ slug: prepared.source.slug,
630
+ ext: 'md',
631
+ body: prepared.source.body,
632
+ provenance: prepared.source.provenance,
633
+ now,
634
+ });
635
+ if (result.deduped) addDeduped(report, file.sourcePath, result.path);
636
+ else if (result.written) report.staged += 1;
637
+ } catch (error) {
638
+ addFailure(report, file.sourcePath, error.message);
639
+ }
640
+ }
641
+ return { ok: true, bulk: true, report };
642
+ }
643
+
644
+ function fetchPinnedUrl(
645
+ rawUrl,
646
+ { headers = {}, pinnedAddress, pinnedFamily, timeoutMs = FETCH_TIMEOUT_MS, maxBytes = MAX_RESPONSE_BYTES } = {},
647
+ ) {
648
+ const url = new URL(rawUrl);
649
+ if (!pinnedAddress) throw new Error(`cannot fetch "${url.hostname}" without a pinned address.`);
650
+ const client = url.protocol === 'https:' ? httpsRequest : httpRequest;
651
+ const port = url.port || (url.protocol === 'https:' ? 443 : 80);
652
+ const host = url.host;
653
+
654
+ return new Promise((resolvePromise, reject) => {
655
+ const request = client(
656
+ {
657
+ protocol: url.protocol,
658
+ hostname: pinnedAddress,
659
+ family: pinnedFamily,
660
+ port,
661
+ method: 'GET',
662
+ path: `${url.pathname || '/'}${url.search}`,
663
+ headers: { ...headers, host },
664
+ servername: url.hostname,
665
+ timeout: timeoutMs,
666
+ },
667
+ (response) => {
668
+ const chunks = [];
669
+ let received = 0;
670
+ let failed = false;
671
+ const fail = (error) => {
672
+ if (failed) return;
673
+ failed = true;
674
+ reject(error);
675
+ response.destroy();
676
+ request.destroy();
677
+ };
678
+ response.on('data', (chunk) => {
679
+ received += chunk.length;
680
+ if (received > maxBytes) {
681
+ fail(new Error(`URL fetch response exceeded ${maxBytes} bytes.`));
682
+ return;
683
+ }
684
+ chunks.push(chunk);
685
+ });
686
+ response.on('aborted', () => fail(new Error('URL fetch response ended before the declared body was received.')));
687
+ response.on('error', fail);
688
+ response.on('end', () => {
689
+ if (failed) return;
690
+ const status = Number(response.statusCode || 0);
691
+ let contentLength;
692
+ try {
693
+ contentLength = declaredContentLength(response.headers);
694
+ } catch (error) {
695
+ reject(error);
696
+ return;
697
+ }
698
+ if (contentLength !== null && received !== contentLength) {
699
+ reject(new Error(`URL fetch response was truncated (${received}/${contentLength} bytes).`));
700
+ return;
701
+ }
702
+ let decodedText;
703
+ try {
704
+ decodedText = decodeBody(Buffer.concat(chunks), response.headers, maxBytes);
705
+ } catch (error) {
706
+ reject(error);
707
+ return;
708
+ }
709
+ resolvePromise({
710
+ status,
711
+ ok: status >= 200 && status < 300,
712
+ headers: { get: (name) => responseHeader(response.headers, name) },
713
+ text: async () => decodedText,
714
+ });
715
+ });
716
+ },
717
+ );
718
+ request.on('timeout', () => request.destroy(new Error(`URL fetch timed out after ${timeoutMs}ms.`)));
719
+ request.on('error', reject);
720
+ request.end();
721
+ });
722
+ }
723
+
724
+ async function fetchText(rawUrl, { fetchImpl, lookupImpl, allowPrivateNetworkForTest = false, maxResponseBytes = MAX_RESPONSE_BYTES }) {
725
+ let url = new URL(rawUrl);
726
+ for (let redirect = 0; redirect <= 5; redirect += 1) {
727
+ const resolved = await resolveFetchablePublicUrl(url, lookupImpl, { allowPrivateNetworkForTest });
728
+ const response = await (fetchImpl || fetchPinnedUrl)(url.toString(), {
729
+ redirect: 'manual',
730
+ headers: {
731
+ 'user-agent': 'phronesis-ingest-cli/0.0.0',
732
+ 'accept-encoding': 'gzip, deflate, br',
733
+ connection: 'close',
734
+ },
735
+ pinnedAddress: resolved.address,
736
+ pinnedFamily: resolved.family,
737
+ timeoutMs: FETCH_TIMEOUT_MS,
738
+ maxBytes: maxResponseBytes,
739
+ });
740
+ const status = Number(response.status || 0);
741
+ if (REDIRECT_STATUSES.has(status)) {
742
+ const location = response.headers?.get?.('location');
743
+ if (!location) throw new Error(`URL returned redirect ${status} without a Location header.`);
744
+ url = new URL(location, url);
745
+ continue;
746
+ }
747
+ if (!response.ok) throw new Error(`URL fetch failed with HTTP ${status}.`);
748
+ return {
749
+ body: await response.text(),
750
+ contentType: response.headers?.get?.('content-type') || '',
751
+ finalUrl: url.toString(),
752
+ };
753
+ }
754
+ throw new Error('URL fetch followed too many redirects.');
755
+ }
756
+
757
+ async function sourceFromPaste({ stdin, now, title, slug }) {
758
+ const body = await readStream(stdin);
759
+ if (body.trim() === '') throw new Error('paste input was empty; ingest wrote nothing.');
760
+ const derivedSlug = slugFromTitle({ title, slug }) || slugify(firstMeaningfulLine(body)) || 'pasted-source';
761
+ const id = `operator-paste:${sha256Short(body)}`;
762
+ return {
763
+ body,
764
+ slug: derivedSlug,
765
+ provenance: {
766
+ source_connector: 'manual',
767
+ source_external_id: id,
768
+ source_timestamp: now.toISOString(),
769
+ source_url: `operator-paste:${derivedSlug}`,
770
+ content_type: 'paste',
771
+ retrieval_tool: 'operator-paste',
772
+ trust_tier: 'untrusted',
773
+ },
774
+ };
775
+ }
776
+
777
+ async function sourceFromFile({ source, trustTier, now, cwd, absolute: resolvedAbsolute, info: resolvedInfo, maxLocalFileBytes }) {
778
+ const absolute = resolvedAbsolute || resolve(cwd, source);
779
+ const info =
780
+ resolvedInfo ||
781
+ (await stat(absolute).catch((error) => {
782
+ throw new Error(`could not read local file "${source}": ${error.message}`);
783
+ }));
784
+ if (!info.isFile()) throw new Error(`local source "${source}" is not a file.`);
785
+ if (info.size > maxLocalFileBytes) {
786
+ throw new Error(`local source "${source}" exceeds ${maxLocalFileBytes} bytes.`);
787
+ }
788
+ let read;
789
+ try {
790
+ read = await readLocalFileCapped(absolute, maxLocalFileBytes);
791
+ } catch (error) {
792
+ throw new Error(`could not read local file "${source}": ${error.message}`);
793
+ }
794
+ if (read.skipped) throw new Error(`local source "${source}" ${read.skipped}.`);
795
+ const raw = read.buffer.toString('utf8');
796
+ try {
797
+ return prepareLocalFileSource({ absolute, info, raw, trustTier, now });
798
+ } catch (error) {
799
+ if (error.message === 'empty') throw new Error(`local source "${source}" did not produce non-empty text.`);
800
+ throw error;
801
+ }
802
+ }
803
+
804
+ async function sourceFromUrl({ source, now, fetchImpl, lookupImpl, allowPrivateNetworkForTest, maxResponseBytes }) {
805
+ const fetched = await fetchText(source, { fetchImpl, lookupImpl, allowPrivateNetworkForTest, maxResponseBytes });
806
+ const body = looksLikeHtml(fetched) ? stripHtmlToMarkdown(fetched.body) : fetched.body.trim();
807
+ if (body.trim() === '') throw new Error('URL fetch produced empty text; ingest wrote nothing.');
808
+ const clean = sanitizeUrlForProvenance(fetched.finalUrl);
809
+ return {
810
+ body,
811
+ slug: urlSlug(clean),
812
+ provenance: {
813
+ source_connector: 'manual',
814
+ source_external_id: `url:${clean}`,
815
+ source_timestamp: now.toISOString(),
816
+ source_url: clean,
817
+ content_type: 'article',
818
+ retrieval_tool: 'web-fetch',
819
+ trust_tier: 'untrusted',
820
+ fetched_at: now.toISOString(),
821
+ },
822
+ };
823
+ }
824
+
825
+ export async function ingestSource({
826
+ source,
827
+ paste = false,
828
+ trust,
829
+ lane = 'inbox',
830
+ domain,
831
+ dir,
832
+ cwd = process.cwd(),
833
+ stdin = process.stdin,
834
+ fetchImpl,
835
+ lookupImpl = dnsLookup,
836
+ allowPrivateNetworkForTest = false,
837
+ maxResponseBytes = MAX_RESPONSE_BYTES,
838
+ maxLocalFileBytes = MAX_LOCAL_FILE_BYTES,
839
+ sourceConnector,
840
+ sourceExternalId,
841
+ sourceTimestamp,
842
+ title,
843
+ slug,
844
+ now = new Date(),
845
+ } = {}) {
846
+ const installed = resolveInstall({ dir, cwd });
847
+ if (installed.error) throw new Error(installed.error);
848
+ if (typeof domain !== 'string' || domain.trim() === '') throw new Error('ingest requires --domain <slug>.');
849
+ const trustTier = trust || 'untrusted';
850
+ if (!TRUST_TIERS.includes(trustTier)) throw new Error(`--trust must be trusted | untrusted (got "${trust}").`);
851
+ const connectorBridge = hasConnectorBridgeInput({ sourceConnector, sourceExternalId, sourceTimestamp });
852
+ let bridgeProvenance = null;
853
+ if (connectorBridge) {
854
+ bridgeProvenance = connectorBridgeProvenance({ sourceConnector, sourceExternalId, sourceTimestamp });
855
+ if (source !== '-') throw new Error('connector bridge ingest requires - as the stdin source.');
856
+ if (trustTier !== 'untrusted') throw new Error('connector bridge ingest is always untrusted; --trust trusted is refused.');
857
+ if (paste) throw new Error('connector bridge ingest uses - for stdin; --paste is the legacy manual paste mode.');
858
+ }
859
+ if (source === '-' && (typeof title !== 'string' || title.trim() === '')) {
860
+ throw new Error('stdin ingest requires --title <title>; --slug is only an optional exact filename override.');
861
+ }
862
+
863
+ let prepared;
864
+ if (paste || source === '-') {
865
+ if (source && source !== '-') throw new Error('--paste does not accept a source argument; pass content on stdin.');
866
+ if (trustTier === 'trusted') throw new Error('--trust trusted is only allowed for local-file ingest.');
867
+ prepared = await sourceFromPaste({ stdin, now, title, slug });
868
+ } else if (isHttpUrl(source || '')) {
869
+ if (trustTier === 'trusted') throw new Error('--trust trusted is refused for URL ingest; fetched sources are always untrusted.');
870
+ prepared = await sourceFromUrl({ source, now, fetchImpl, lookupImpl, allowPrivateNetworkForTest, maxResponseBytes });
871
+ } else if (isExplicitUrl(source)) {
872
+ const url = new URL(source);
873
+ throw new Error(`URL protocol "${url.protocol}" is not supported (expected http or https).`);
874
+ } else if (source) {
875
+ const absolute = resolve(cwd, source);
876
+ const info = await stat(absolute).catch((error) => {
877
+ throw new Error(`could not read local file "${source}": ${error.message}`);
878
+ });
879
+ if (info.isDirectory()) {
880
+ if (connectorBridge) {
881
+ throw new Error('connector bridge provenance flags are single-source only; use one ingest call per bridge item.');
882
+ }
883
+ return ingestDirectory({
884
+ absolute,
885
+ installRoot: installed.root,
886
+ domain,
887
+ lane,
888
+ trustTier,
889
+ now,
890
+ maxLocalFileBytes,
891
+ });
892
+ }
893
+ prepared = await sourceFromFile({ source, trustTier, now, cwd, absolute, info, maxLocalFileBytes });
894
+ } else {
895
+ throw new Error('ingest requires a <url|file|dir> source, or --paste / - for stdin.');
896
+ }
897
+
898
+ if (connectorBridge) {
899
+ prepared = {
900
+ body: prepared.body,
901
+ slug: slugFromTitle({ title, slug }) || prepared.slug,
902
+ provenance: bridgeProvenance,
903
+ };
904
+ }
905
+
906
+ return stageRawSource({
907
+ installRoot: installed.root,
908
+ domain,
909
+ lane,
910
+ slug: prepared.slug,
911
+ ext: 'md',
912
+ body: prepared.body,
913
+ provenance: prepared.provenance,
914
+ provenanceMode: connectorBridge ? 'connector-bridge' : 'manual',
915
+ now,
916
+ });
917
+ }
918
+
919
+ export async function runIngest(options = {}) {
920
+ try {
921
+ const result = await ingestSource(options);
922
+ if (options.json) {
923
+ const report = result.bulk
924
+ ? result.report
925
+ : {
926
+ staged: result.deduped ? 0 : 1,
927
+ deduped: result.deduped ? 1 : 0,
928
+ deduped_items: result.deduped ? [{ path: toReportPath(singleSourceReportPath(options)), existing_path: toReportPath(result.path) }] : [],
929
+ skipped: [],
930
+ failed: [],
931
+ };
932
+ console.log(JSON.stringify(report, null, 2));
933
+ process.exitCode = 0;
934
+ return result;
935
+ }
936
+ if (result.bulk) {
937
+ console.log(`Staged ${result.report.staged} source(s); deduped ${result.report.deduped} existing source(s).`);
938
+ if (result.report.skipped.length) {
939
+ console.log(`Skipped ${result.report.skipped.length}:`);
940
+ for (const item of result.report.skipped) console.log(` - ${item.path}: ${item.reason}`);
941
+ }
942
+ if (result.report.deduped_items.length) {
943
+ console.log(`Deduped ${result.report.deduped_items.length}:`);
944
+ for (const item of result.report.deduped_items) console.log(` - ${item.path}: already staged at ${item.existing_path}`);
945
+ }
946
+ if (result.report.failed.length) {
947
+ console.log(`Failed ${result.report.failed.length}:`);
948
+ for (const item of result.report.failed) console.log(` - ${item.path}: ${item.reason}`);
949
+ }
950
+ console.log('Now run compile for these raw sources.');
951
+ process.exitCode = 0;
952
+ return result;
953
+ }
954
+ const status = result.deduped ? 'Already staged' : 'Staged 1 source';
955
+ console.log(`${status} to ${result.path}`);
956
+ console.log(`trust_tier: ${result.deduped ? 'unchanged' : options.trust || 'untrusted'}`);
957
+ console.log('Now run compile for this raw source.');
958
+ process.exitCode = 0;
959
+ return result;
960
+ } catch (error) {
961
+ console.error(`ingest: ${error.message}`);
962
+ process.exitCode = 1;
963
+ return null;
964
+ }
965
+ }