@prismer/runtime 2.0.0 → 2.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.cjs CHANGED
@@ -465,7 +465,7 @@ var require_package = __commonJS({
465
465
  "package.json"(exports2, module2) {
466
466
  module2.exports = {
467
467
  name: "@prismer/runtime",
468
- version: "2.0.0",
468
+ version: "2.0.1",
469
469
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
470
470
  type: "module",
471
471
  main: "dist/index.js",
@@ -502,6 +502,7 @@ var require_package = __commonJS({
502
502
  "better-sqlite3": "^11.8.0",
503
503
  commander: "^12.1.0",
504
504
  qrcode: "^1.5.4",
505
+ undici: "^7.24.0",
505
506
  ws: "^8.19.0",
506
507
  yaml: "^2.8.2",
507
508
  zod: "^3.23.8"
@@ -738,6 +739,7 @@ __export(src_exports, {
738
739
  currentSchemaVersion: () => currentSchemaVersion,
739
740
  deriveWsUrl: () => deriveWsUrl,
740
741
  envelope: () => envelope,
742
+ extractHttpUrls: () => extractHttpUrls,
741
743
  getRoleTemplate: () => getRoleTemplate,
742
744
  handleAgentMessageDispatch: () => handleAgentMessageDispatch,
743
745
  handleDispatch: () => handleDispatch,
@@ -2946,8 +2948,11 @@ function envelope(type, payload, requestId) {
2946
2948
 
2947
2949
  // src/asset-cache.ts
2948
2950
  var import_node_crypto2 = require("crypto");
2951
+ var import_node_dns = require("dns");
2949
2952
  var import_node_fs5 = require("fs");
2953
+ var import_node_net = require("net");
2950
2954
  var import_node_path6 = require("path");
2955
+ var import_undici = require("undici");
2951
2956
  function rowToCached(row) {
2952
2957
  return {
2953
2958
  contentHash: row.content_hash,
@@ -3068,6 +3073,73 @@ var AssetCache = class {
3068
3073
  pin: false
3069
3074
  };
3070
3075
  }
3076
+ /**
3077
+ * Fetch a public http(s) URL, content-hash dedup, cache locally.
3078
+ *
3079
+ * Mirrors `getOrFetch` (cache key = sha256 of body bytes), but the input
3080
+ * is a URL whose hash we don't know up front. The fetcher applies:
3081
+ *
3082
+ * - SSRF guard (resolve hostname, reject loopback / RFC1918 / link-local
3083
+ * / cloud-metadata / IPv6 ULA + link-local). Cross-host redirects
3084
+ * re-validate the new host before each hop.
3085
+ * - manual redirect handling (max 3 hops).
3086
+ * - hard timeout (default 15 s, override via env
3087
+ * `PRISMER_URL_FETCH_TIMEOUT_MS`).
3088
+ * - streaming body read with abort-at-limit (default 5 MiB, override
3089
+ * via env `PRISMER_URL_FETCH_MAX_BYTES`). Truncated bodies are NOT
3090
+ * cached.
3091
+ * - non-2xx → throws (caller should leave the URL in place and emit
3092
+ * an `error` observation).
3093
+ *
3094
+ * On success returns { cached, finalUrl, durationMs }; the caller can
3095
+ * pin / unpin the hash like any other asset.
3096
+ */
3097
+ async getOrFetchUrl(url, opts) {
3098
+ const started = Date.now();
3099
+ const maxBytes = opts?.maxBytes ?? defaultMaxBytes();
3100
+ const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs();
3101
+ const maxRedirects = opts?.maxRedirects ?? 3;
3102
+ const userAgent = opts?.userAgent ?? "prismer-daemon";
3103
+ const { body, finalUrl, mime } = await fetchUrlWithGuards(url, {
3104
+ signal: opts?.signal,
3105
+ maxBytes,
3106
+ timeoutMs,
3107
+ maxRedirects,
3108
+ userAgent
3109
+ });
3110
+ const hash = sha256(body);
3111
+ const existing = this.get(hash);
3112
+ if (existing) {
3113
+ return { cached: existing, finalUrl, durationMs: Date.now() - started };
3114
+ }
3115
+ const localPath = this.pathFor(hash);
3116
+ if (!(0, import_node_fs5.existsSync)((0, import_node_path6.dirname)(localPath))) {
3117
+ (0, import_node_fs5.mkdirSync)((0, import_node_path6.dirname)(localPath), { recursive: true });
3118
+ }
3119
+ const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3120
+ (0, import_node_fs5.writeFileSync)(tmpPath, body);
3121
+ (0, import_node_fs5.renameSync)(tmpPath, localPath);
3122
+ const now = Date.now();
3123
+ this.db.prepare(
3124
+ `INSERT OR REPLACE INTO cached_assets
3125
+ (content_hash, size_bytes, mime, local_path, fetched_at, last_used_at, pin)
3126
+ VALUES (?, ?, ?, ?, ?, ?, 0)`
3127
+ ).run(hash, body.length, mime, localPath, now, now);
3128
+ this.evictIfOver();
3129
+ return {
3130
+ cached: {
3131
+ contentHash: hash,
3132
+ sizeBytes: body.length,
3133
+ mime,
3134
+ localPath,
3135
+ fetchedAt: now,
3136
+ lastUsedAt: now,
3137
+ pin: false
3138
+ },
3139
+ finalUrl,
3140
+ durationMs: now - started
3141
+ };
3142
+ }
3071
3143
  /** Insert an already-on-disk asset into the cache (e.g., asset just produced by adapter). */
3072
3144
  registerLocal(hash, localPath, mime) {
3073
3145
  const actualHash = sha256(readFileBuffer(localPath));
@@ -3119,6 +3191,184 @@ function sha256(buf) {
3119
3191
  function readFileBuffer(path9) {
3120
3192
  return (0, import_node_fs5.readFileSync)(path9);
3121
3193
  }
3194
+ var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
3195
+ var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
3196
+ function defaultMaxBytes() {
3197
+ const v = Number(process.env.PRISMER_URL_FETCH_MAX_BYTES);
3198
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_MAX_BYTES;
3199
+ }
3200
+ function defaultTimeoutMs() {
3201
+ const v = Number(process.env.PRISMER_URL_FETCH_TIMEOUT_MS);
3202
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_TIMEOUT_MS;
3203
+ }
3204
+ var urlFetchDeps = {};
3205
+ function activeFetch() {
3206
+ return urlFetchDeps.fetch ?? globalThis.fetch;
3207
+ }
3208
+ async function activeResolveHostname(host) {
3209
+ if (urlFetchDeps.resolveHostname) return urlFetchDeps.resolveHostname(host);
3210
+ if ((0, import_node_net.isIP)(host)) return [host];
3211
+ const res = await import_node_dns.promises.lookup(host, { all: true, verbatim: true });
3212
+ return res.map((r) => r.address);
3213
+ }
3214
+ function isForbiddenIp(ip) {
3215
+ if ((0, import_node_net.isIPv4)(ip)) {
3216
+ const parts = ip.split(".").map((n) => Number(n));
3217
+ if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) {
3218
+ return true;
3219
+ }
3220
+ const [a = 0, b = 0] = parts;
3221
+ if (a === 127) return true;
3222
+ if (a === 10) return true;
3223
+ if (a === 172 && b >= 16 && b <= 31) return true;
3224
+ if (a === 192 && b === 168) return true;
3225
+ if (a === 169 && b === 254) return true;
3226
+ if (a === 0) return true;
3227
+ return false;
3228
+ }
3229
+ if ((0, import_node_net.isIPv6)(ip)) {
3230
+ const lower = ip.toLowerCase();
3231
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return true;
3232
+ if (lower === "::" || lower === "0:0:0:0:0:0:0:0") return true;
3233
+ if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true;
3234
+ if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;
3235
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(lower);
3236
+ if (mapped) return isForbiddenIp(mapped[1]);
3237
+ return false;
3238
+ }
3239
+ return true;
3240
+ }
3241
+ async function assertHostAllowed(hostname5) {
3242
+ let ips;
3243
+ try {
3244
+ ips = await activeResolveHostname(hostname5);
3245
+ } catch (err) {
3246
+ throw new Error(`hostname resolution failed for ${hostname5}: ${err.message}`);
3247
+ }
3248
+ if (ips.length === 0) {
3249
+ throw new Error(`no IPs resolved for ${hostname5}`);
3250
+ }
3251
+ for (const ip of ips) {
3252
+ if (isForbiddenIp(ip)) {
3253
+ throw new Error(`SSRF guard: ${hostname5} \u2192 ${ip} is in a forbidden range`);
3254
+ }
3255
+ }
3256
+ return ips[0];
3257
+ }
3258
+ async function fetchUrlWithGuards(rawUrl, opts) {
3259
+ let currentUrl;
3260
+ try {
3261
+ currentUrl = new URL(rawUrl);
3262
+ } catch {
3263
+ throw new Error(`invalid URL: ${rawUrl}`);
3264
+ }
3265
+ for (let hop = 0; hop <= opts.maxRedirects; hop += 1) {
3266
+ if (currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") {
3267
+ throw new Error(`unsupported scheme: ${currentUrl.protocol}`);
3268
+ }
3269
+ const pinnedIp = await assertHostAllowed(currentUrl.hostname);
3270
+ const controller = new AbortController();
3271
+ const timer = setTimeout(() => controller.abort(new Error("timeout")), opts.timeoutMs);
3272
+ if (opts.signal) {
3273
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
3274
+ else opts.signal.addEventListener("abort", () => controller.abort(opts.signal.reason), { once: true });
3275
+ }
3276
+ const usingMockedFetch = urlFetchDeps.fetch !== void 0;
3277
+ const dispatcher = usingMockedFetch ? void 0 : new import_undici.Agent({
3278
+ connect: {
3279
+ lookup: (_hostname, _options, cb) => {
3280
+ cb(null, pinnedIp, (0, import_node_net.isIPv6)(pinnedIp) ? 6 : 4);
3281
+ }
3282
+ }
3283
+ });
3284
+ try {
3285
+ let res;
3286
+ try {
3287
+ res = await activeFetch()(currentUrl, {
3288
+ method: "GET",
3289
+ redirect: "manual",
3290
+ headers: { "User-Agent": opts.userAgent, Accept: "*/*" },
3291
+ signal: controller.signal,
3292
+ // `dispatcher` is undici-specific; Node's built-in fetch (which
3293
+ // IS undici under the hood) accepts it via this option.
3294
+ ...dispatcher ? { dispatcher } : {}
3295
+ });
3296
+ } catch (err) {
3297
+ clearTimeout(timer);
3298
+ throw new Error(`fetch failed: ${err.message}`);
3299
+ }
3300
+ if (res.status >= 300 && res.status < 400) {
3301
+ clearTimeout(timer);
3302
+ const loc = res.headers.get("location");
3303
+ if (!loc) throw new Error(`redirect ${res.status} with no Location header`);
3304
+ try {
3305
+ currentUrl = new URL(loc, currentUrl);
3306
+ } catch {
3307
+ throw new Error(`invalid redirect Location: ${loc}`);
3308
+ }
3309
+ try {
3310
+ await res.arrayBuffer();
3311
+ } catch {
3312
+ }
3313
+ if (hop === opts.maxRedirects) {
3314
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
3315
+ }
3316
+ continue;
3317
+ }
3318
+ if (!res.ok) {
3319
+ clearTimeout(timer);
3320
+ throw new Error(`HTTP ${res.status}`);
3321
+ }
3322
+ const contentLength = Number(res.headers.get("content-length"));
3323
+ if (Number.isFinite(contentLength) && contentLength > opts.maxBytes) {
3324
+ clearTimeout(timer);
3325
+ controller.abort(new Error("content-length exceeds limit"));
3326
+ throw new Error(`body too large: content-length=${contentLength} > ${opts.maxBytes}`);
3327
+ }
3328
+ const chunks = [];
3329
+ let total = 0;
3330
+ const reader = res.body?.getReader();
3331
+ try {
3332
+ if (reader) {
3333
+ for (; ; ) {
3334
+ const { done, value } = await reader.read();
3335
+ if (done) break;
3336
+ if (value) {
3337
+ total += value.byteLength;
3338
+ if (total > opts.maxBytes) {
3339
+ controller.abort(new Error("body exceeds limit"));
3340
+ try {
3341
+ await reader.cancel();
3342
+ } catch {
3343
+ }
3344
+ throw new Error(`body too large: read ${total} > ${opts.maxBytes}`);
3345
+ }
3346
+ chunks.push(value);
3347
+ }
3348
+ }
3349
+ } else {
3350
+ const ab = await res.arrayBuffer();
3351
+ if (ab.byteLength > opts.maxBytes) {
3352
+ throw new Error(`body too large: ${ab.byteLength} > ${opts.maxBytes}`);
3353
+ }
3354
+ chunks.push(new Uint8Array(ab));
3355
+ total = ab.byteLength;
3356
+ }
3357
+ } finally {
3358
+ clearTimeout(timer);
3359
+ }
3360
+ const body = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), total);
3361
+ return {
3362
+ body,
3363
+ finalUrl: currentUrl.toString(),
3364
+ mime: res.headers.get("content-type")
3365
+ };
3366
+ } finally {
3367
+ await dispatcher?.close().catch(() => void 0);
3368
+ }
3369
+ }
3370
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
3371
+ }
3122
3372
 
3123
3373
  // src/daemon/asset/mirror.ts
3124
3374
  var import_node_fs6 = require("fs");
@@ -3371,6 +3621,8 @@ var ParseClaimController = class {
3371
3621
  // src/uri-resolver.ts
3372
3622
  var URI_REGEX_WORKSPACE = /prismer:\/\/workspace\/([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
3373
3623
  var URI_REGEX_LEGACY = /prismer:\/\/(?!workspace\/)([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
3624
+ var HTTP_URL_REGEX = /https?:\/\/[^\s<>"'`]+/g;
3625
+ var URL_TRAILING_PUNCT_RE = /[,.;:!?)\]}'"]+$/;
3374
3626
  function parseUris(text) {
3375
3627
  if (!text) return [];
3376
3628
  const out = [];
@@ -3433,12 +3685,20 @@ var UriResolver = class {
3433
3685
  return cached.localPath;
3434
3686
  }
3435
3687
  /**
3436
- * Walk a string, replace every `prismer://(asset|file)/...` with `file://<localPath>`.
3437
- * Unrecognized URIs pass through. Returns rewritten text + the list of pinned hashes.
3688
+ * Walk a string, replace every `prismer://(asset|file)/...` and
3689
+ * `https://…` / `http://…` URL with `file://<localPath>`.
3690
+ *
3691
+ * Unrecognized URIs pass through unchanged. Returns rewritten text, the
3692
+ * list of pinned hashes, and one observation per http(s) URL the resolver
3693
+ * attempted (success + error both surface) so the dispatch can include
3694
+ * them in `reply.assetObservability`.
3695
+ *
3696
+ * `urlCache` lets the caller dedupe URL fetches across multiple rewrite
3697
+ * calls within a single dispatch (e.g. prompt + each context entry).
3698
+ * Pass the same Map instance to every rewrite() / rewriteAll() call.
3438
3699
  */
3439
3700
  async rewrite(text, opts) {
3440
3701
  const uris = parseUris(text);
3441
- if (uris.length === 0) return { text, resolvedHashes: [] };
3442
3702
  const replacements = /* @__PURE__ */ new Map();
3443
3703
  const resolvedHashes = [];
3444
3704
  for (const u of uris) {
@@ -3461,6 +3721,46 @@ var UriResolver = class {
3461
3721
  console.warn(`[uri-resolver] failed to resolve ${u.raw}: ${err.message}`);
3462
3722
  }
3463
3723
  }
3724
+ if (opts?.fetchUrls !== false) {
3725
+ const urlCache = opts?.urlCache;
3726
+ const urls = extractHttpUrls(text);
3727
+ for (const original of urls) {
3728
+ try {
3729
+ let resolution = urlCache?.get(original);
3730
+ if (!resolution) {
3731
+ const { cached, finalUrl, durationMs } = await this.assetCache.getOrFetchUrl(original, {
3732
+ signal: opts?.signal
3733
+ });
3734
+ resolution = { hash: cached.contentHash, localPath: cached.localPath, sizeBytes: cached.sizeBytes, mime: cached.mime, finalUrl, durationMs };
3735
+ urlCache?.set(original, resolution);
3736
+ opts?.urlObservations?.push({
3737
+ contentHash: cached.contentHash,
3738
+ mime: cached.mime,
3739
+ sizeBytes: cached.sizeBytes,
3740
+ strategy: "fetched-https",
3741
+ originalUrl: original,
3742
+ finalUrl,
3743
+ durationMs
3744
+ });
3745
+ }
3746
+ replacements.set(original, `file://${resolution.localPath}`);
3747
+ resolvedHashes.push(resolution.hash);
3748
+ if (opts?.pin) this.assetCache.pin(resolution.hash);
3749
+ } catch (err) {
3750
+ const message = err.message;
3751
+ console.warn(`[uri-resolver] failed to fetch ${original}: ${message}`);
3752
+ opts?.urlObservations?.push({
3753
+ contentHash: "",
3754
+ mime: null,
3755
+ sizeBytes: null,
3756
+ strategy: "error",
3757
+ originalUrl: original,
3758
+ error: message
3759
+ });
3760
+ }
3761
+ }
3762
+ }
3763
+ if (replacements.size === 0) return { text, resolvedHashes };
3464
3764
  let rewritten = text;
3465
3765
  for (const [raw, sub] of replacements) {
3466
3766
  rewritten = rewritten.split(raw).join(sub);
@@ -3498,6 +3798,27 @@ var UriResolver = class {
3498
3798
  function dedupe(xs) {
3499
3799
  return Array.from(new Set(xs));
3500
3800
  }
3801
+ function extractHttpUrls(text) {
3802
+ if (!text) return [];
3803
+ const seen = /* @__PURE__ */ new Set();
3804
+ const out = [];
3805
+ for (const m of text.matchAll(HTTP_URL_REGEX)) {
3806
+ let url = m[0];
3807
+ const punct = URL_TRAILING_PUNCT_RE.exec(url);
3808
+ if (punct) url = url.slice(0, -punct[0].length);
3809
+ if (!url) continue;
3810
+ try {
3811
+ const parsed = new URL(url);
3812
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue;
3813
+ } catch {
3814
+ continue;
3815
+ }
3816
+ if (seen.has(url)) continue;
3817
+ seen.add(url);
3818
+ out.push(url);
3819
+ }
3820
+ return out;
3821
+ }
3501
3822
 
3502
3823
  // src/daemon/dispatch.ts
3503
3824
  var import_node_fs8 = require("fs");
@@ -3522,29 +3843,141 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
3522
3843
  for (const entry of entries) {
3523
3844
  const slug = sanitizeSlug(trimmedStringFrom(entry.skill?.slug) ?? trimmedStringFrom(entry.slug));
3524
3845
  const skillId = trimmedStringFrom(entry.skill?.id);
3525
- const content = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
3526
- if (!slug || !content) {
3846
+ if (!slug) {
3527
3847
  skipped++;
3528
- if (slug && !content) {
3529
- process.stderr.write(`[daemon] skill sync skipped ${slug}: missing content
3530
- `);
3531
- await ackSkillSync(cloud, agentImUserId, { slug, error: "missing content" }, signal);
3848
+ continue;
3849
+ }
3850
+ let files = null;
3851
+ const manifestRaw = entry.skill?.contentManifest;
3852
+ if (typeof manifestRaw === "string" && manifestRaw.trim()) {
3853
+ files = parseManifest(manifestRaw, slug);
3854
+ }
3855
+ if (!files) {
3856
+ const legacyContent = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
3857
+ if (legacyContent) {
3858
+ const buf = Buffer.from(legacyContent, "utf8");
3859
+ files = [
3860
+ {
3861
+ path: "SKILL.md",
3862
+ size: buf.byteLength,
3863
+ sha256: sha256Buffer(buf),
3864
+ inline: true,
3865
+ content: buf.toString("base64")
3866
+ }
3867
+ ];
3532
3868
  }
3869
+ }
3870
+ if (!files || files.length === 0) {
3871
+ skipped++;
3872
+ process.stderr.write(`[daemon] skill sync skipped ${slug}: no manifest or content
3873
+ `);
3874
+ await ackSkillSync(cloud, agentImUserId, { skillId, slug, error: "missing content" }, signal);
3533
3875
  continue;
3534
3876
  }
3535
- const revision = sha2562(content);
3536
3877
  const skillDir = (0, import_node_path8.join)(skillsRoot, slug);
3537
3878
  await import_node_fs7.promises.mkdir(skillDir, { recursive: true });
3538
- const target = (0, import_node_path8.join)(skillDir, "SKILL.md");
3539
- const existing = await import_node_fs7.promises.readFile(target, "utf8").catch(() => null);
3540
- if (existing != null && sha2562(existing) === revision) {
3541
- unchanged++;
3542
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
3879
+ const localFiles = await walkLocalDir(skillDir);
3880
+ let dirty = false;
3881
+ let perFileFailures = 0;
3882
+ for (const file of files) {
3883
+ if (!isSafeRelativePath(file.path)) {
3884
+ process.stderr.write(`[daemon] skill sync ${slug}: skipping suspicious path "${file.path}"
3885
+ `);
3886
+ perFileFailures++;
3887
+ continue;
3888
+ }
3889
+ const targetPath = (0, import_node_path8.join)(skillDir, file.path);
3890
+ const normalizedTarget = targetPath + (targetPath.endsWith(import_node_path8.sep) ? "" : "");
3891
+ if (normalizedTarget !== skillDir && !normalizedTarget.startsWith(skillDir + import_node_path8.sep)) {
3892
+ process.stderr.write(`[daemon] skill sync ${slug}: path escapes skillDir "${file.path}"
3893
+ `);
3894
+ perFileFailures++;
3895
+ continue;
3896
+ }
3897
+ const existingHash = localFiles.get(file.path);
3898
+ if (existingHash === file.sha256) {
3899
+ localFiles.delete(file.path);
3900
+ continue;
3901
+ }
3902
+ let bytes = null;
3903
+ try {
3904
+ if (file.inline !== false && typeof file.content === "string") {
3905
+ bytes = Buffer.from(file.content, "base64");
3906
+ } else if (typeof file.url === "string" && file.url) {
3907
+ bytes = await downloadUrl(file.url, signal);
3908
+ } else {
3909
+ process.stderr.write(
3910
+ `[daemon] skill sync ${slug}: file ${file.path} has neither inline content nor url
3911
+ `
3912
+ );
3913
+ perFileFailures++;
3914
+ continue;
3915
+ }
3916
+ } catch (err) {
3917
+ process.stderr.write(
3918
+ `[daemon] skill sync ${slug}: failed to fetch ${file.path}: ${err.message}
3919
+ `
3920
+ );
3921
+ perFileFailures++;
3922
+ continue;
3923
+ }
3924
+ const downloadedHash = sha256Buffer(bytes);
3925
+ if (downloadedHash !== file.sha256) {
3926
+ process.stderr.write(
3927
+ `[daemon] skill sync ${slug}: hash mismatch for ${file.path} (expected ${file.sha256}, got ${downloadedHash})
3928
+ `
3929
+ );
3930
+ perFileFailures++;
3931
+ continue;
3932
+ }
3933
+ await import_node_fs7.promises.mkdir((0, import_node_path8.dirname)(targetPath), { recursive: true });
3934
+ await import_node_fs7.promises.writeFile(targetPath, bytes);
3935
+ localFiles.delete(file.path);
3936
+ dirty = true;
3937
+ }
3938
+ for (const orphan of localFiles.keys()) {
3939
+ try {
3940
+ await import_node_fs7.promises.unlink((0, import_node_path8.join)(skillDir, orphan));
3941
+ dirty = true;
3942
+ } catch (err) {
3943
+ if (err.code !== "ENOENT") {
3944
+ process.stderr.write(
3945
+ `[daemon] skill sync ${slug}: failed to remove orphan ${orphan}: ${err.message}
3946
+ `
3947
+ );
3948
+ }
3949
+ }
3950
+ }
3951
+ if (perFileFailures > 0) {
3952
+ skipped++;
3953
+ await ackSkillSync(
3954
+ cloud,
3955
+ agentImUserId,
3956
+ { skillId, slug, error: `${perFileFailures} file(s) failed` },
3957
+ signal
3958
+ );
3543
3959
  continue;
3544
3960
  }
3545
- await import_node_fs7.promises.writeFile(target, content, "utf8");
3546
- synced++;
3547
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
3961
+ const localMerkle = computeMerkle(files);
3962
+ const declaredRevision = trimmedStringFrom(entry.skill?.contentManifestRevision);
3963
+ const revision = declaredRevision ?? localMerkle;
3964
+ if (declaredRevision && declaredRevision !== localMerkle) {
3965
+ process.stderr.write(
3966
+ `[daemon] skill sync ${slug}: declared revision ${declaredRevision} != computed ${localMerkle}; using computed
3967
+ `
3968
+ );
3969
+ }
3970
+ if (dirty) {
3971
+ synced++;
3972
+ } else {
3973
+ unchanged++;
3974
+ }
3975
+ await ackSkillSync(
3976
+ cloud,
3977
+ agentImUserId,
3978
+ { skillId, slug, revision: declaredRevision === localMerkle ? declaredRevision : localMerkle },
3979
+ signal
3980
+ );
3548
3981
  }
3549
3982
  return { synced, skipped, unchanged };
3550
3983
  }
@@ -3562,9 +3995,96 @@ function resolveSkillsRoot(profile) {
3562
3995
  }
3563
3996
  return null;
3564
3997
  }
3565
- function sha2562(value) {
3998
+ function sha256Buffer(value) {
3566
3999
  return (0, import_node_crypto3.createHash)("sha256").update(value).digest("hex");
3567
4000
  }
4001
+ function computeMerkle(files) {
4002
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
4003
+ const lines = sorted.map((f) => `${f.path}:${f.sha256}`).join("\n");
4004
+ return (0, import_node_crypto3.createHash)("sha256").update(lines).digest("hex");
4005
+ }
4006
+ function parseManifest(raw, slug) {
4007
+ try {
4008
+ const parsed = JSON.parse(raw);
4009
+ const arr = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.files) ? parsed.files : null;
4010
+ if (!Array.isArray(arr)) {
4011
+ process.stderr.write(`[daemon] skill sync ${slug}: contentManifest is not an array
4012
+ `);
4013
+ return null;
4014
+ }
4015
+ const files = [];
4016
+ for (const item of arr) {
4017
+ if (!item || typeof item !== "object") continue;
4018
+ const rec = item;
4019
+ const path9 = typeof rec.path === "string" ? rec.path : null;
4020
+ const sha2563 = typeof rec.sha256 === "string" ? rec.sha256 : null;
4021
+ const size = typeof rec.size === "number" && Number.isFinite(rec.size) && rec.size >= 0 ? Math.floor(rec.size) : null;
4022
+ if (!path9 || !sha2563 || size === null) continue;
4023
+ const content = typeof rec.content === "string" ? rec.content : void 0;
4024
+ const url = typeof rec.url === "string" ? rec.url : void 0;
4025
+ const inline = typeof rec.inline === "boolean" ? rec.inline : content !== void 0 ? true : void 0;
4026
+ files.push({ path: path9, size, sha256: sha2563, inline, content, url });
4027
+ }
4028
+ return files;
4029
+ } catch (err) {
4030
+ process.stderr.write(
4031
+ `[daemon] skill sync ${slug}: invalid contentManifest JSON: ${err.message}
4032
+ `
4033
+ );
4034
+ return null;
4035
+ }
4036
+ }
4037
+ function isSafeRelativePath(p) {
4038
+ if (typeof p !== "string" || !p) return false;
4039
+ if (p.length > 512) return false;
4040
+ if (p.includes("\0")) return false;
4041
+ if (p.startsWith("/") || p.startsWith("\\")) return false;
4042
+ if (/^[A-Za-z]:[\\/]/.test(p)) return false;
4043
+ const segs = p.replace(/\\/g, "/").split("/");
4044
+ for (const seg of segs) {
4045
+ if (seg === "" || seg === "." || seg === "..") return false;
4046
+ }
4047
+ return true;
4048
+ }
4049
+ async function walkLocalDir(root) {
4050
+ const out = /* @__PURE__ */ new Map();
4051
+ async function walk2(dir) {
4052
+ let entries;
4053
+ try {
4054
+ entries = await import_node_fs7.promises.readdir(dir, { withFileTypes: true });
4055
+ } catch (err) {
4056
+ if (err.code === "ENOENT") return;
4057
+ throw err;
4058
+ }
4059
+ for (const ent of entries) {
4060
+ const full = (0, import_node_path8.join)(dir, ent.name);
4061
+ if (ent.isDirectory()) {
4062
+ await walk2(full);
4063
+ } else if (ent.isFile()) {
4064
+ try {
4065
+ const buf = await import_node_fs7.promises.readFile(full);
4066
+ const rel = (0, import_node_path8.relative)(root, full).split(import_node_path8.sep).join("/");
4067
+ out.set(rel, sha256Buffer(buf));
4068
+ } catch {
4069
+ }
4070
+ }
4071
+ }
4072
+ }
4073
+ await walk2(root);
4074
+ return out;
4075
+ }
4076
+ async function downloadUrl(url, signal) {
4077
+ const lower = url.toLowerCase();
4078
+ if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
4079
+ throw new Error(`unsupported url scheme: ${url.slice(0, 32)}`);
4080
+ }
4081
+ const res = await fetch(url, { signal });
4082
+ if (!res.ok) {
4083
+ throw new Error(`fetch ${url} failed: HTTP ${res.status}`);
4084
+ }
4085
+ const ab = await res.arrayBuffer();
4086
+ return Buffer.from(ab);
4087
+ }
3568
4088
  function normalizeInstalledSkills(data) {
3569
4089
  if (Array.isArray(data)) return data.filter(isInstalledSkillEntry);
3570
4090
  if (!data || typeof data !== "object" || Array.isArray(data)) return [];
@@ -3684,12 +4204,22 @@ async function handleDispatch(payload, requestId, deps) {
3684
4204
  payload.prompt = hashRefResult.text;
3685
4205
  }
3686
4206
  }
3687
- const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, { pin: true });
4207
+ const urlCache = /* @__PURE__ */ new Map();
4208
+ const urlObservations = [];
4209
+ const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, {
4210
+ pin: true,
4211
+ urlCache,
4212
+ urlObservations
4213
+ });
3688
4214
  resolvedHashes.push(...rewrittenPrompt.resolvedHashes);
3689
4215
  let rewrittenContext = [];
3690
4216
  if (payload.context && payload.context.length > 0) {
3691
4217
  const contents = payload.context.map((e) => e.content);
3692
- const r = await deps.uriResolver.rewriteAll(contents, { pin: true });
4218
+ const r = await deps.uriResolver.rewriteAll(contents, {
4219
+ pin: true,
4220
+ urlCache,
4221
+ urlObservations
4222
+ });
3693
4223
  resolvedHashes.push(...r.resolvedHashes);
3694
4224
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
3695
4225
  }
@@ -3809,7 +4339,7 @@ async function handleDispatch(payload, requestId, deps) {
3809
4339
  } : result.error,
3810
4340
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
3811
4341
  metrics: result.metrics,
3812
- ...assetResolution.observability.length > 0 ? { assetObservability: assetResolution.observability } : {}
4342
+ ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
3813
4343
  };
3814
4344
  await writeBridgeMetadata(payload.taskId, deps.cloud, result.metadata, deps.signal);
3815
4345
  await writeObservabilityMetadata(
@@ -5020,10 +5550,10 @@ async function walkAndDigest(root, current) {
5020
5550
  }
5021
5551
  if (!st.isFile()) continue;
5022
5552
  const buf = await import_node_fs9.promises.readFile(full);
5023
- const sha2564 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
5553
+ const sha2563 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
5024
5554
  out.push({
5025
5555
  path: rel,
5026
- sha256: sha2564,
5556
+ sha256: sha2563,
5027
5557
  sizeBytes: st.size,
5028
5558
  mtime: Math.floor(st.mtimeMs)
5029
5559
  });
@@ -5788,7 +6318,7 @@ var MemoryStore = class {
5788
6318
  }
5789
6319
  const db = this.requireDb();
5790
6320
  const now = Date.now();
5791
- const contentHash = sha2563(input.content);
6321
+ const contentHash = sha2562(input.content);
5792
6322
  const payload = sealPlaintext(input.content);
5793
6323
  if (payload.kind !== "inline") {
5794
6324
  throw new Error("MemoryStore.write: non-inline payload not yet supported in phase-0");
@@ -5982,7 +6512,7 @@ var MemoryStore = class {
5982
6512
  };
5983
6513
  }
5984
6514
  };
5985
- function sha2563(s) {
6515
+ function sha2562(s) {
5986
6516
  return (0, import_node_crypto5.createHash)("sha256").update(s, "utf8").digest("hex");
5987
6517
  }
5988
6518
 
@@ -11246,11 +11776,11 @@ function buildAssetCommand() {
11246
11776
  }
11247
11777
  printAssetDetail("Asset", assetId, body);
11248
11778
  }, { code: "asset_get_failed" }));
11249
- cmd.command("by-hash <sha256>").description("Fetch asset metadata by content hash").option("--workspace-id <id>", "Workspace id (required by the cloud API)").option("--json", "Output machine-readable JSON").action(runAction(async (sha2564, opts) => {
11779
+ cmd.command("by-hash <sha256>").description("Fetch asset metadata by content hash").option("--workspace-id <id>", "Workspace id (required by the cloud API)").option("--json", "Output machine-readable JSON").action(runAction(async (sha2563, opts) => {
11250
11780
  const cloud = mkCloud();
11251
11781
  const query = new URLSearchParams();
11252
11782
  if (opts.workspaceId) query.set("wsId", opts.workspaceId);
11253
- const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2564)}${query.toString() ? `?${query.toString()}` : ""}`;
11783
+ const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2563)}${query.toString() ? `?${query.toString()}` : ""}`;
11254
11784
  const res = await cloud.request("GET", path9);
11255
11785
  if (!res.ok) exitWithError(`asset by-hash failed (${res.status}): ${res.error?.message ?? "request failed"}`, { code: res.error?.code ?? "asset_by_hash_failed" });
11256
11786
  const body = res.data;
@@ -11261,7 +11791,7 @@ function buildAssetCommand() {
11261
11791
  printJson(body ?? null);
11262
11792
  return;
11263
11793
  }
11264
- printAssetDetail("Asset hash", sha2564, body);
11794
+ printAssetDetail("Asset hash", sha2563, body);
11265
11795
  }, { code: "asset_by_hash_failed" }));
11266
11796
  return cmd;
11267
11797
  }
@@ -14350,7 +14880,7 @@ async function readResponseError(res) {
14350
14880
 
14351
14881
  // src/cli/index.ts
14352
14882
  init_ui();
14353
- var VERSION = "2.0.0";
14883
+ var VERSION = "2.0.1";
14354
14884
  function buildProgram() {
14355
14885
  const program = new import_commander20.Command("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
14356
14886
  program.addCommand(buildBannerCommand());
@@ -14409,6 +14939,7 @@ async function runCli(argv = process.argv) {
14409
14939
  currentSchemaVersion,
14410
14940
  deriveWsUrl,
14411
14941
  envelope,
14942
+ extractHttpUrls,
14412
14943
  getRoleTemplate,
14413
14944
  handleAgentMessageDispatch,
14414
14945
  handleDispatch,