@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.js CHANGED
@@ -442,7 +442,7 @@ var require_package = __commonJS({
442
442
  "package.json"(exports, module) {
443
443
  module.exports = {
444
444
  name: "@prismer/runtime",
445
- version: "2.0.0",
445
+ version: "2.0.1",
446
446
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
447
447
  type: "module",
448
448
  main: "dist/index.js",
@@ -479,6 +479,7 @@ var require_package = __commonJS({
479
479
  "better-sqlite3": "^11.8.0",
480
480
  commander: "^12.1.0",
481
481
  qrcode: "^1.5.4",
482
+ undici: "^7.24.0",
482
483
  ws: "^8.19.0",
483
484
  yaml: "^2.8.2",
484
485
  zod: "^3.23.8"
@@ -2873,8 +2874,11 @@ function envelope(type, payload, requestId) {
2873
2874
 
2874
2875
  // src/asset-cache.ts
2875
2876
  import { createHash } from "crypto";
2877
+ import { promises as dnsp } from "dns";
2876
2878
  import { existsSync as existsSync4, mkdirSync as mkdirSync4, readFileSync as readFileSync3, renameSync, statSync, unlinkSync, writeFileSync as writeFileSync3 } from "fs";
2879
+ import { isIP, isIPv4, isIPv6 } from "net";
2877
2880
  import { dirname as dirname4, join as join5 } from "path";
2881
+ import { Agent as UndiciAgent } from "undici";
2878
2882
  function rowToCached(row) {
2879
2883
  return {
2880
2884
  contentHash: row.content_hash,
@@ -2995,6 +2999,73 @@ var AssetCache = class {
2995
2999
  pin: false
2996
3000
  };
2997
3001
  }
3002
+ /**
3003
+ * Fetch a public http(s) URL, content-hash dedup, cache locally.
3004
+ *
3005
+ * Mirrors `getOrFetch` (cache key = sha256 of body bytes), but the input
3006
+ * is a URL whose hash we don't know up front. The fetcher applies:
3007
+ *
3008
+ * - SSRF guard (resolve hostname, reject loopback / RFC1918 / link-local
3009
+ * / cloud-metadata / IPv6 ULA + link-local). Cross-host redirects
3010
+ * re-validate the new host before each hop.
3011
+ * - manual redirect handling (max 3 hops).
3012
+ * - hard timeout (default 15 s, override via env
3013
+ * `PRISMER_URL_FETCH_TIMEOUT_MS`).
3014
+ * - streaming body read with abort-at-limit (default 5 MiB, override
3015
+ * via env `PRISMER_URL_FETCH_MAX_BYTES`). Truncated bodies are NOT
3016
+ * cached.
3017
+ * - non-2xx → throws (caller should leave the URL in place and emit
3018
+ * an `error` observation).
3019
+ *
3020
+ * On success returns { cached, finalUrl, durationMs }; the caller can
3021
+ * pin / unpin the hash like any other asset.
3022
+ */
3023
+ async getOrFetchUrl(url, opts) {
3024
+ const started = Date.now();
3025
+ const maxBytes = opts?.maxBytes ?? defaultMaxBytes();
3026
+ const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs();
3027
+ const maxRedirects = opts?.maxRedirects ?? 3;
3028
+ const userAgent = opts?.userAgent ?? "prismer-daemon";
3029
+ const { body, finalUrl, mime } = await fetchUrlWithGuards(url, {
3030
+ signal: opts?.signal,
3031
+ maxBytes,
3032
+ timeoutMs,
3033
+ maxRedirects,
3034
+ userAgent
3035
+ });
3036
+ const hash = sha256(body);
3037
+ const existing = this.get(hash);
3038
+ if (existing) {
3039
+ return { cached: existing, finalUrl, durationMs: Date.now() - started };
3040
+ }
3041
+ const localPath = this.pathFor(hash);
3042
+ if (!existsSync4(dirname4(localPath))) {
3043
+ mkdirSync4(dirname4(localPath), { recursive: true });
3044
+ }
3045
+ const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
3046
+ writeFileSync3(tmpPath, body);
3047
+ renameSync(tmpPath, localPath);
3048
+ const now = Date.now();
3049
+ this.db.prepare(
3050
+ `INSERT OR REPLACE INTO cached_assets
3051
+ (content_hash, size_bytes, mime, local_path, fetched_at, last_used_at, pin)
3052
+ VALUES (?, ?, ?, ?, ?, ?, 0)`
3053
+ ).run(hash, body.length, mime, localPath, now, now);
3054
+ this.evictIfOver();
3055
+ return {
3056
+ cached: {
3057
+ contentHash: hash,
3058
+ sizeBytes: body.length,
3059
+ mime,
3060
+ localPath,
3061
+ fetchedAt: now,
3062
+ lastUsedAt: now,
3063
+ pin: false
3064
+ },
3065
+ finalUrl,
3066
+ durationMs: now - started
3067
+ };
3068
+ }
2998
3069
  /** Insert an already-on-disk asset into the cache (e.g., asset just produced by adapter). */
2999
3070
  registerLocal(hash, localPath, mime) {
3000
3071
  const actualHash = sha256(readFileBuffer(localPath));
@@ -3046,6 +3117,184 @@ function sha256(buf) {
3046
3117
  function readFileBuffer(path9) {
3047
3118
  return readFileSync3(path9);
3048
3119
  }
3120
+ var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
3121
+ var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
3122
+ function defaultMaxBytes() {
3123
+ const v = Number(process.env.PRISMER_URL_FETCH_MAX_BYTES);
3124
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_MAX_BYTES;
3125
+ }
3126
+ function defaultTimeoutMs() {
3127
+ const v = Number(process.env.PRISMER_URL_FETCH_TIMEOUT_MS);
3128
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_TIMEOUT_MS;
3129
+ }
3130
+ var urlFetchDeps = {};
3131
+ function activeFetch() {
3132
+ return urlFetchDeps.fetch ?? globalThis.fetch;
3133
+ }
3134
+ async function activeResolveHostname(host) {
3135
+ if (urlFetchDeps.resolveHostname) return urlFetchDeps.resolveHostname(host);
3136
+ if (isIP(host)) return [host];
3137
+ const res = await dnsp.lookup(host, { all: true, verbatim: true });
3138
+ return res.map((r) => r.address);
3139
+ }
3140
+ function isForbiddenIp(ip) {
3141
+ if (isIPv4(ip)) {
3142
+ const parts = ip.split(".").map((n) => Number(n));
3143
+ if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) {
3144
+ return true;
3145
+ }
3146
+ const [a = 0, b = 0] = parts;
3147
+ if (a === 127) return true;
3148
+ if (a === 10) return true;
3149
+ if (a === 172 && b >= 16 && b <= 31) return true;
3150
+ if (a === 192 && b === 168) return true;
3151
+ if (a === 169 && b === 254) return true;
3152
+ if (a === 0) return true;
3153
+ return false;
3154
+ }
3155
+ if (isIPv6(ip)) {
3156
+ const lower = ip.toLowerCase();
3157
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return true;
3158
+ if (lower === "::" || lower === "0:0:0:0:0:0:0:0") return true;
3159
+ if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true;
3160
+ if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;
3161
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(lower);
3162
+ if (mapped) return isForbiddenIp(mapped[1]);
3163
+ return false;
3164
+ }
3165
+ return true;
3166
+ }
3167
+ async function assertHostAllowed(hostname5) {
3168
+ let ips;
3169
+ try {
3170
+ ips = await activeResolveHostname(hostname5);
3171
+ } catch (err) {
3172
+ throw new Error(`hostname resolution failed for ${hostname5}: ${err.message}`);
3173
+ }
3174
+ if (ips.length === 0) {
3175
+ throw new Error(`no IPs resolved for ${hostname5}`);
3176
+ }
3177
+ for (const ip of ips) {
3178
+ if (isForbiddenIp(ip)) {
3179
+ throw new Error(`SSRF guard: ${hostname5} \u2192 ${ip} is in a forbidden range`);
3180
+ }
3181
+ }
3182
+ return ips[0];
3183
+ }
3184
+ async function fetchUrlWithGuards(rawUrl, opts) {
3185
+ let currentUrl;
3186
+ try {
3187
+ currentUrl = new URL(rawUrl);
3188
+ } catch {
3189
+ throw new Error(`invalid URL: ${rawUrl}`);
3190
+ }
3191
+ for (let hop = 0; hop <= opts.maxRedirects; hop += 1) {
3192
+ if (currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") {
3193
+ throw new Error(`unsupported scheme: ${currentUrl.protocol}`);
3194
+ }
3195
+ const pinnedIp = await assertHostAllowed(currentUrl.hostname);
3196
+ const controller = new AbortController();
3197
+ const timer = setTimeout(() => controller.abort(new Error("timeout")), opts.timeoutMs);
3198
+ if (opts.signal) {
3199
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
3200
+ else opts.signal.addEventListener("abort", () => controller.abort(opts.signal.reason), { once: true });
3201
+ }
3202
+ const usingMockedFetch = urlFetchDeps.fetch !== void 0;
3203
+ const dispatcher = usingMockedFetch ? void 0 : new UndiciAgent({
3204
+ connect: {
3205
+ lookup: (_hostname, _options, cb) => {
3206
+ cb(null, pinnedIp, isIPv6(pinnedIp) ? 6 : 4);
3207
+ }
3208
+ }
3209
+ });
3210
+ try {
3211
+ let res;
3212
+ try {
3213
+ res = await activeFetch()(currentUrl, {
3214
+ method: "GET",
3215
+ redirect: "manual",
3216
+ headers: { "User-Agent": opts.userAgent, Accept: "*/*" },
3217
+ signal: controller.signal,
3218
+ // `dispatcher` is undici-specific; Node's built-in fetch (which
3219
+ // IS undici under the hood) accepts it via this option.
3220
+ ...dispatcher ? { dispatcher } : {}
3221
+ });
3222
+ } catch (err) {
3223
+ clearTimeout(timer);
3224
+ throw new Error(`fetch failed: ${err.message}`);
3225
+ }
3226
+ if (res.status >= 300 && res.status < 400) {
3227
+ clearTimeout(timer);
3228
+ const loc = res.headers.get("location");
3229
+ if (!loc) throw new Error(`redirect ${res.status} with no Location header`);
3230
+ try {
3231
+ currentUrl = new URL(loc, currentUrl);
3232
+ } catch {
3233
+ throw new Error(`invalid redirect Location: ${loc}`);
3234
+ }
3235
+ try {
3236
+ await res.arrayBuffer();
3237
+ } catch {
3238
+ }
3239
+ if (hop === opts.maxRedirects) {
3240
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
3241
+ }
3242
+ continue;
3243
+ }
3244
+ if (!res.ok) {
3245
+ clearTimeout(timer);
3246
+ throw new Error(`HTTP ${res.status}`);
3247
+ }
3248
+ const contentLength = Number(res.headers.get("content-length"));
3249
+ if (Number.isFinite(contentLength) && contentLength > opts.maxBytes) {
3250
+ clearTimeout(timer);
3251
+ controller.abort(new Error("content-length exceeds limit"));
3252
+ throw new Error(`body too large: content-length=${contentLength} > ${opts.maxBytes}`);
3253
+ }
3254
+ const chunks = [];
3255
+ let total = 0;
3256
+ const reader = res.body?.getReader();
3257
+ try {
3258
+ if (reader) {
3259
+ for (; ; ) {
3260
+ const { done, value } = await reader.read();
3261
+ if (done) break;
3262
+ if (value) {
3263
+ total += value.byteLength;
3264
+ if (total > opts.maxBytes) {
3265
+ controller.abort(new Error("body exceeds limit"));
3266
+ try {
3267
+ await reader.cancel();
3268
+ } catch {
3269
+ }
3270
+ throw new Error(`body too large: read ${total} > ${opts.maxBytes}`);
3271
+ }
3272
+ chunks.push(value);
3273
+ }
3274
+ }
3275
+ } else {
3276
+ const ab = await res.arrayBuffer();
3277
+ if (ab.byteLength > opts.maxBytes) {
3278
+ throw new Error(`body too large: ${ab.byteLength} > ${opts.maxBytes}`);
3279
+ }
3280
+ chunks.push(new Uint8Array(ab));
3281
+ total = ab.byteLength;
3282
+ }
3283
+ } finally {
3284
+ clearTimeout(timer);
3285
+ }
3286
+ const body = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), total);
3287
+ return {
3288
+ body,
3289
+ finalUrl: currentUrl.toString(),
3290
+ mime: res.headers.get("content-type")
3291
+ };
3292
+ } finally {
3293
+ await dispatcher?.close().catch(() => void 0);
3294
+ }
3295
+ }
3296
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
3297
+ }
3049
3298
 
3050
3299
  // src/daemon/asset/mirror.ts
3051
3300
  import { existsSync as existsSync5, mkdirSync as mkdirSync5, readFileSync as readFileSync4, writeFileSync as writeFileSync4 } from "fs";
@@ -3298,6 +3547,8 @@ var ParseClaimController = class {
3298
3547
  // src/uri-resolver.ts
3299
3548
  var URI_REGEX_WORKSPACE = /prismer:\/\/workspace\/([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
3300
3549
  var URI_REGEX_LEGACY = /prismer:\/\/(?!workspace\/)([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
3550
+ var HTTP_URL_REGEX = /https?:\/\/[^\s<>"'`]+/g;
3551
+ var URL_TRAILING_PUNCT_RE = /[,.;:!?)\]}'"]+$/;
3301
3552
  function parseUris(text) {
3302
3553
  if (!text) return [];
3303
3554
  const out = [];
@@ -3360,12 +3611,20 @@ var UriResolver = class {
3360
3611
  return cached.localPath;
3361
3612
  }
3362
3613
  /**
3363
- * Walk a string, replace every `prismer://(asset|file)/...` with `file://<localPath>`.
3364
- * Unrecognized URIs pass through. Returns rewritten text + the list of pinned hashes.
3614
+ * Walk a string, replace every `prismer://(asset|file)/...` and
3615
+ * `https://…` / `http://…` URL with `file://<localPath>`.
3616
+ *
3617
+ * Unrecognized URIs pass through unchanged. Returns rewritten text, the
3618
+ * list of pinned hashes, and one observation per http(s) URL the resolver
3619
+ * attempted (success + error both surface) so the dispatch can include
3620
+ * them in `reply.assetObservability`.
3621
+ *
3622
+ * `urlCache` lets the caller dedupe URL fetches across multiple rewrite
3623
+ * calls within a single dispatch (e.g. prompt + each context entry).
3624
+ * Pass the same Map instance to every rewrite() / rewriteAll() call.
3365
3625
  */
3366
3626
  async rewrite(text, opts) {
3367
3627
  const uris = parseUris(text);
3368
- if (uris.length === 0) return { text, resolvedHashes: [] };
3369
3628
  const replacements = /* @__PURE__ */ new Map();
3370
3629
  const resolvedHashes = [];
3371
3630
  for (const u of uris) {
@@ -3388,6 +3647,46 @@ var UriResolver = class {
3388
3647
  console.warn(`[uri-resolver] failed to resolve ${u.raw}: ${err.message}`);
3389
3648
  }
3390
3649
  }
3650
+ if (opts?.fetchUrls !== false) {
3651
+ const urlCache = opts?.urlCache;
3652
+ const urls = extractHttpUrls(text);
3653
+ for (const original of urls) {
3654
+ try {
3655
+ let resolution = urlCache?.get(original);
3656
+ if (!resolution) {
3657
+ const { cached, finalUrl, durationMs } = await this.assetCache.getOrFetchUrl(original, {
3658
+ signal: opts?.signal
3659
+ });
3660
+ resolution = { hash: cached.contentHash, localPath: cached.localPath, sizeBytes: cached.sizeBytes, mime: cached.mime, finalUrl, durationMs };
3661
+ urlCache?.set(original, resolution);
3662
+ opts?.urlObservations?.push({
3663
+ contentHash: cached.contentHash,
3664
+ mime: cached.mime,
3665
+ sizeBytes: cached.sizeBytes,
3666
+ strategy: "fetched-https",
3667
+ originalUrl: original,
3668
+ finalUrl,
3669
+ durationMs
3670
+ });
3671
+ }
3672
+ replacements.set(original, `file://${resolution.localPath}`);
3673
+ resolvedHashes.push(resolution.hash);
3674
+ if (opts?.pin) this.assetCache.pin(resolution.hash);
3675
+ } catch (err) {
3676
+ const message = err.message;
3677
+ console.warn(`[uri-resolver] failed to fetch ${original}: ${message}`);
3678
+ opts?.urlObservations?.push({
3679
+ contentHash: "",
3680
+ mime: null,
3681
+ sizeBytes: null,
3682
+ strategy: "error",
3683
+ originalUrl: original,
3684
+ error: message
3685
+ });
3686
+ }
3687
+ }
3688
+ }
3689
+ if (replacements.size === 0) return { text, resolvedHashes };
3391
3690
  let rewritten = text;
3392
3691
  for (const [raw, sub] of replacements) {
3393
3692
  rewritten = rewritten.split(raw).join(sub);
@@ -3425,6 +3724,27 @@ var UriResolver = class {
3425
3724
  function dedupe(xs) {
3426
3725
  return Array.from(new Set(xs));
3427
3726
  }
3727
+ function extractHttpUrls(text) {
3728
+ if (!text) return [];
3729
+ const seen = /* @__PURE__ */ new Set();
3730
+ const out = [];
3731
+ for (const m of text.matchAll(HTTP_URL_REGEX)) {
3732
+ let url = m[0];
3733
+ const punct = URL_TRAILING_PUNCT_RE.exec(url);
3734
+ if (punct) url = url.slice(0, -punct[0].length);
3735
+ if (!url) continue;
3736
+ try {
3737
+ const parsed = new URL(url);
3738
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue;
3739
+ } catch {
3740
+ continue;
3741
+ }
3742
+ if (seen.has(url)) continue;
3743
+ seen.add(url);
3744
+ out.push(url);
3745
+ }
3746
+ return out;
3747
+ }
3428
3748
 
3429
3749
  // src/daemon/dispatch.ts
3430
3750
  import { readFileSync as readFileSync5, promises as fsp3 } from "fs";
@@ -3432,7 +3752,7 @@ import * as path from "path";
3432
3752
 
3433
3753
  // src/daemon/skill-sync.ts
3434
3754
  import { promises as fsp2 } from "fs";
3435
- import { join as join7 } from "path";
3755
+ import { dirname as dirname5, join as join7, relative, sep } from "path";
3436
3756
  import { createHash as createHash2 } from "crypto";
3437
3757
  async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, signal) {
3438
3758
  if (!agentImUserId) return { synced: 0, skipped: 0, unchanged: 0 };
@@ -3449,29 +3769,141 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
3449
3769
  for (const entry of entries) {
3450
3770
  const slug = sanitizeSlug(trimmedStringFrom(entry.skill?.slug) ?? trimmedStringFrom(entry.slug));
3451
3771
  const skillId = trimmedStringFrom(entry.skill?.id);
3452
- const content = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
3453
- if (!slug || !content) {
3772
+ if (!slug) {
3454
3773
  skipped++;
3455
- if (slug && !content) {
3456
- process.stderr.write(`[daemon] skill sync skipped ${slug}: missing content
3457
- `);
3458
- await ackSkillSync(cloud, agentImUserId, { slug, error: "missing content" }, signal);
3774
+ continue;
3775
+ }
3776
+ let files = null;
3777
+ const manifestRaw = entry.skill?.contentManifest;
3778
+ if (typeof manifestRaw === "string" && manifestRaw.trim()) {
3779
+ files = parseManifest(manifestRaw, slug);
3780
+ }
3781
+ if (!files) {
3782
+ const legacyContent = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
3783
+ if (legacyContent) {
3784
+ const buf = Buffer.from(legacyContent, "utf8");
3785
+ files = [
3786
+ {
3787
+ path: "SKILL.md",
3788
+ size: buf.byteLength,
3789
+ sha256: sha256Buffer(buf),
3790
+ inline: true,
3791
+ content: buf.toString("base64")
3792
+ }
3793
+ ];
3459
3794
  }
3795
+ }
3796
+ if (!files || files.length === 0) {
3797
+ skipped++;
3798
+ process.stderr.write(`[daemon] skill sync skipped ${slug}: no manifest or content
3799
+ `);
3800
+ await ackSkillSync(cloud, agentImUserId, { skillId, slug, error: "missing content" }, signal);
3460
3801
  continue;
3461
3802
  }
3462
- const revision = sha2562(content);
3463
3803
  const skillDir = join7(skillsRoot, slug);
3464
3804
  await fsp2.mkdir(skillDir, { recursive: true });
3465
- const target = join7(skillDir, "SKILL.md");
3466
- const existing = await fsp2.readFile(target, "utf8").catch(() => null);
3467
- if (existing != null && sha2562(existing) === revision) {
3468
- unchanged++;
3469
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
3805
+ const localFiles = await walkLocalDir(skillDir);
3806
+ let dirty = false;
3807
+ let perFileFailures = 0;
3808
+ for (const file of files) {
3809
+ if (!isSafeRelativePath(file.path)) {
3810
+ process.stderr.write(`[daemon] skill sync ${slug}: skipping suspicious path "${file.path}"
3811
+ `);
3812
+ perFileFailures++;
3813
+ continue;
3814
+ }
3815
+ const targetPath = join7(skillDir, file.path);
3816
+ const normalizedTarget = targetPath + (targetPath.endsWith(sep) ? "" : "");
3817
+ if (normalizedTarget !== skillDir && !normalizedTarget.startsWith(skillDir + sep)) {
3818
+ process.stderr.write(`[daemon] skill sync ${slug}: path escapes skillDir "${file.path}"
3819
+ `);
3820
+ perFileFailures++;
3821
+ continue;
3822
+ }
3823
+ const existingHash = localFiles.get(file.path);
3824
+ if (existingHash === file.sha256) {
3825
+ localFiles.delete(file.path);
3826
+ continue;
3827
+ }
3828
+ let bytes = null;
3829
+ try {
3830
+ if (file.inline !== false && typeof file.content === "string") {
3831
+ bytes = Buffer.from(file.content, "base64");
3832
+ } else if (typeof file.url === "string" && file.url) {
3833
+ bytes = await downloadUrl(file.url, signal);
3834
+ } else {
3835
+ process.stderr.write(
3836
+ `[daemon] skill sync ${slug}: file ${file.path} has neither inline content nor url
3837
+ `
3838
+ );
3839
+ perFileFailures++;
3840
+ continue;
3841
+ }
3842
+ } catch (err) {
3843
+ process.stderr.write(
3844
+ `[daemon] skill sync ${slug}: failed to fetch ${file.path}: ${err.message}
3845
+ `
3846
+ );
3847
+ perFileFailures++;
3848
+ continue;
3849
+ }
3850
+ const downloadedHash = sha256Buffer(bytes);
3851
+ if (downloadedHash !== file.sha256) {
3852
+ process.stderr.write(
3853
+ `[daemon] skill sync ${slug}: hash mismatch for ${file.path} (expected ${file.sha256}, got ${downloadedHash})
3854
+ `
3855
+ );
3856
+ perFileFailures++;
3857
+ continue;
3858
+ }
3859
+ await fsp2.mkdir(dirname5(targetPath), { recursive: true });
3860
+ await fsp2.writeFile(targetPath, bytes);
3861
+ localFiles.delete(file.path);
3862
+ dirty = true;
3863
+ }
3864
+ for (const orphan of localFiles.keys()) {
3865
+ try {
3866
+ await fsp2.unlink(join7(skillDir, orphan));
3867
+ dirty = true;
3868
+ } catch (err) {
3869
+ if (err.code !== "ENOENT") {
3870
+ process.stderr.write(
3871
+ `[daemon] skill sync ${slug}: failed to remove orphan ${orphan}: ${err.message}
3872
+ `
3873
+ );
3874
+ }
3875
+ }
3876
+ }
3877
+ if (perFileFailures > 0) {
3878
+ skipped++;
3879
+ await ackSkillSync(
3880
+ cloud,
3881
+ agentImUserId,
3882
+ { skillId, slug, error: `${perFileFailures} file(s) failed` },
3883
+ signal
3884
+ );
3470
3885
  continue;
3471
3886
  }
3472
- await fsp2.writeFile(target, content, "utf8");
3473
- synced++;
3474
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
3887
+ const localMerkle = computeMerkle(files);
3888
+ const declaredRevision = trimmedStringFrom(entry.skill?.contentManifestRevision);
3889
+ const revision = declaredRevision ?? localMerkle;
3890
+ if (declaredRevision && declaredRevision !== localMerkle) {
3891
+ process.stderr.write(
3892
+ `[daemon] skill sync ${slug}: declared revision ${declaredRevision} != computed ${localMerkle}; using computed
3893
+ `
3894
+ );
3895
+ }
3896
+ if (dirty) {
3897
+ synced++;
3898
+ } else {
3899
+ unchanged++;
3900
+ }
3901
+ await ackSkillSync(
3902
+ cloud,
3903
+ agentImUserId,
3904
+ { skillId, slug, revision: declaredRevision === localMerkle ? declaredRevision : localMerkle },
3905
+ signal
3906
+ );
3475
3907
  }
3476
3908
  return { synced, skipped, unchanged };
3477
3909
  }
@@ -3489,9 +3921,96 @@ function resolveSkillsRoot(profile) {
3489
3921
  }
3490
3922
  return null;
3491
3923
  }
3492
- function sha2562(value) {
3924
+ function sha256Buffer(value) {
3493
3925
  return createHash2("sha256").update(value).digest("hex");
3494
3926
  }
3927
+ function computeMerkle(files) {
3928
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
3929
+ const lines = sorted.map((f) => `${f.path}:${f.sha256}`).join("\n");
3930
+ return createHash2("sha256").update(lines).digest("hex");
3931
+ }
3932
+ function parseManifest(raw, slug) {
3933
+ try {
3934
+ const parsed = JSON.parse(raw);
3935
+ const arr = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.files) ? parsed.files : null;
3936
+ if (!Array.isArray(arr)) {
3937
+ process.stderr.write(`[daemon] skill sync ${slug}: contentManifest is not an array
3938
+ `);
3939
+ return null;
3940
+ }
3941
+ const files = [];
3942
+ for (const item of arr) {
3943
+ if (!item || typeof item !== "object") continue;
3944
+ const rec = item;
3945
+ const path9 = typeof rec.path === "string" ? rec.path : null;
3946
+ const sha2563 = typeof rec.sha256 === "string" ? rec.sha256 : null;
3947
+ const size = typeof rec.size === "number" && Number.isFinite(rec.size) && rec.size >= 0 ? Math.floor(rec.size) : null;
3948
+ if (!path9 || !sha2563 || size === null) continue;
3949
+ const content = typeof rec.content === "string" ? rec.content : void 0;
3950
+ const url = typeof rec.url === "string" ? rec.url : void 0;
3951
+ const inline = typeof rec.inline === "boolean" ? rec.inline : content !== void 0 ? true : void 0;
3952
+ files.push({ path: path9, size, sha256: sha2563, inline, content, url });
3953
+ }
3954
+ return files;
3955
+ } catch (err) {
3956
+ process.stderr.write(
3957
+ `[daemon] skill sync ${slug}: invalid contentManifest JSON: ${err.message}
3958
+ `
3959
+ );
3960
+ return null;
3961
+ }
3962
+ }
3963
+ function isSafeRelativePath(p) {
3964
+ if (typeof p !== "string" || !p) return false;
3965
+ if (p.length > 512) return false;
3966
+ if (p.includes("\0")) return false;
3967
+ if (p.startsWith("/") || p.startsWith("\\")) return false;
3968
+ if (/^[A-Za-z]:[\\/]/.test(p)) return false;
3969
+ const segs = p.replace(/\\/g, "/").split("/");
3970
+ for (const seg of segs) {
3971
+ if (seg === "" || seg === "." || seg === "..") return false;
3972
+ }
3973
+ return true;
3974
+ }
3975
+ async function walkLocalDir(root) {
3976
+ const out = /* @__PURE__ */ new Map();
3977
+ async function walk2(dir) {
3978
+ let entries;
3979
+ try {
3980
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
3981
+ } catch (err) {
3982
+ if (err.code === "ENOENT") return;
3983
+ throw err;
3984
+ }
3985
+ for (const ent of entries) {
3986
+ const full = join7(dir, ent.name);
3987
+ if (ent.isDirectory()) {
3988
+ await walk2(full);
3989
+ } else if (ent.isFile()) {
3990
+ try {
3991
+ const buf = await fsp2.readFile(full);
3992
+ const rel = relative(root, full).split(sep).join("/");
3993
+ out.set(rel, sha256Buffer(buf));
3994
+ } catch {
3995
+ }
3996
+ }
3997
+ }
3998
+ }
3999
+ await walk2(root);
4000
+ return out;
4001
+ }
4002
+ async function downloadUrl(url, signal) {
4003
+ const lower = url.toLowerCase();
4004
+ if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
4005
+ throw new Error(`unsupported url scheme: ${url.slice(0, 32)}`);
4006
+ }
4007
+ const res = await fetch(url, { signal });
4008
+ if (!res.ok) {
4009
+ throw new Error(`fetch ${url} failed: HTTP ${res.status}`);
4010
+ }
4011
+ const ab = await res.arrayBuffer();
4012
+ return Buffer.from(ab);
4013
+ }
3495
4014
  function normalizeInstalledSkills(data) {
3496
4015
  if (Array.isArray(data)) return data.filter(isInstalledSkillEntry);
3497
4016
  if (!data || typeof data !== "object" || Array.isArray(data)) return [];
@@ -3611,12 +4130,22 @@ async function handleDispatch(payload, requestId, deps) {
3611
4130
  payload.prompt = hashRefResult.text;
3612
4131
  }
3613
4132
  }
3614
- const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, { pin: true });
4133
+ const urlCache = /* @__PURE__ */ new Map();
4134
+ const urlObservations = [];
4135
+ const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, {
4136
+ pin: true,
4137
+ urlCache,
4138
+ urlObservations
4139
+ });
3615
4140
  resolvedHashes.push(...rewrittenPrompt.resolvedHashes);
3616
4141
  let rewrittenContext = [];
3617
4142
  if (payload.context && payload.context.length > 0) {
3618
4143
  const contents = payload.context.map((e) => e.content);
3619
- const r = await deps.uriResolver.rewriteAll(contents, { pin: true });
4144
+ const r = await deps.uriResolver.rewriteAll(contents, {
4145
+ pin: true,
4146
+ urlCache,
4147
+ urlObservations
4148
+ });
3620
4149
  resolvedHashes.push(...r.resolvedHashes);
3621
4150
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
3622
4151
  }
@@ -3736,7 +4265,7 @@ async function handleDispatch(payload, requestId, deps) {
3736
4265
  } : result.error,
3737
4266
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
3738
4267
  metrics: result.metrics,
3739
- ...assetResolution.observability.length > 0 ? { assetObservability: assetResolution.observability } : {}
4268
+ ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
3740
4269
  };
3741
4270
  await writeBridgeMetadata(payload.taskId, deps.cloud, result.metadata, deps.signal);
3742
4271
  await writeObservabilityMetadata(
@@ -4947,10 +5476,10 @@ async function walkAndDigest(root, current) {
4947
5476
  }
4948
5477
  if (!st.isFile()) continue;
4949
5478
  const buf = await fs.readFile(full);
4950
- const sha2564 = createHash3("sha256").update(buf).digest("hex");
5479
+ const sha2563 = createHash3("sha256").update(buf).digest("hex");
4951
5480
  out.push({
4952
5481
  path: rel,
4953
- sha256: sha2564,
5482
+ sha256: sha2563,
4954
5483
  sizeBytes: st.size,
4955
5484
  mtime: Math.floor(st.mtimeMs)
4956
5485
  });
@@ -5715,7 +6244,7 @@ var MemoryStore = class {
5715
6244
  }
5716
6245
  const db = this.requireDb();
5717
6246
  const now = Date.now();
5718
- const contentHash = sha2563(input.content);
6247
+ const contentHash = sha2562(input.content);
5719
6248
  const payload = sealPlaintext(input.content);
5720
6249
  if (payload.kind !== "inline") {
5721
6250
  throw new Error("MemoryStore.write: non-inline payload not yet supported in phase-0");
@@ -5909,7 +6438,7 @@ var MemoryStore = class {
5909
6438
  };
5910
6439
  }
5911
6440
  };
5912
- function sha2563(s) {
6441
+ function sha2562(s) {
5913
6442
  return createHash4("sha256").update(s, "utf8").digest("hex");
5914
6443
  }
5915
6444
 
@@ -10174,7 +10703,7 @@ import {
10174
10703
  writeFileSync as writeFileSync7
10175
10704
  } from "fs";
10176
10705
  import { homedir as homedir7 } from "os";
10177
- import { dirname as dirname8, join as join16 } from "path";
10706
+ import { dirname as dirname9, join as join16 } from "path";
10178
10707
  import { Command } from "commander";
10179
10708
  init_util();
10180
10709
  init_ui();
@@ -10434,7 +10963,7 @@ function runHooks(spec, opts) {
10434
10963
  backup = backupIfExists(markerPath);
10435
10964
  writeFileSync7(markerPath, JSON.stringify(prismerHookMarker(spec), null, 2) + "\n", "utf8");
10436
10965
  } else {
10437
- mkdirSync8(dirname8(planned.path), { recursive: true });
10966
+ mkdirSync8(dirname9(planned.path), { recursive: true });
10438
10967
  backup = backupIfExists(planned.path);
10439
10968
  const merged = mergeHookJson(planned.path, spec);
10440
10969
  writeFileSync7(planned.path, JSON.stringify(merged, null, 2) + "\n", "utf8");
@@ -11104,7 +11633,7 @@ function whichBinary2(bin) {
11104
11633
  import { createHash as createHash7 } from "crypto";
11105
11634
  import { Command as Command3 } from "commander";
11106
11635
  import { existsSync as existsSync13, lstatSync, readdirSync, readFileSync as readFileSync11, writeFileSync as writeFileSync8 } from "fs";
11107
- import { basename as basename3, dirname as dirname9, join as join17, relative as relative3 } from "path";
11636
+ import { basename as basename3, dirname as dirname10, join as join17, relative as relative4 } from "path";
11108
11637
  init_util();
11109
11638
  init_ui();
11110
11639
  function buildAssetCommand() {
@@ -11179,11 +11708,11 @@ function buildAssetCommand() {
11179
11708
  }
11180
11709
  printAssetDetail("Asset", assetId, body);
11181
11710
  }, { code: "asset_get_failed" }));
11182
- 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) => {
11711
+ 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) => {
11183
11712
  const cloud = mkCloud();
11184
11713
  const query = new URLSearchParams();
11185
11714
  if (opts.workspaceId) query.set("wsId", opts.workspaceId);
11186
- const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2564)}${query.toString() ? `?${query.toString()}` : ""}`;
11715
+ const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2563)}${query.toString() ? `?${query.toString()}` : ""}`;
11187
11716
  const res = await cloud.request("GET", path9);
11188
11717
  if (!res.ok) exitWithError(`asset by-hash failed (${res.status}): ${res.error?.message ?? "request failed"}`, { code: res.error?.code ?? "asset_by_hash_failed" });
11189
11718
  const body = res.data;
@@ -11194,7 +11723,7 @@ function buildAssetCommand() {
11194
11723
  printJson(body ?? null);
11195
11724
  return;
11196
11725
  }
11197
- printAssetDetail("Asset hash", sha2564, body);
11726
+ printAssetDetail("Asset hash", sha2563, body);
11198
11727
  }, { code: "asset_by_hash_failed" }));
11199
11728
  return cmd;
11200
11729
  }
@@ -11287,7 +11816,7 @@ async function uploadAssetPath(inputPath, opts) {
11287
11816
  if (files.length === 0) exitWithError(`directory has no uploadable files: ${inputPath}`);
11288
11817
  const items = [];
11289
11818
  for (const file of files) {
11290
- const relDir = dirname9(relative3(inputPath, file));
11819
+ const relDir = dirname10(relative4(inputPath, file));
11291
11820
  const folderPath = combineFolderPath(opts.folderPath, relDir === "." ? "" : relDir);
11292
11821
  const response = await uploadAsset(file, { ...opts, folderPath });
11293
11822
  items.push({ file, folderPath: folderPath ?? null, response });
@@ -12874,7 +13403,7 @@ import { Command as Command13 } from "commander";
12874
13403
  import { execFileSync as execFileSync2 } from "child_process";
12875
13404
  import { existsSync as existsSync18, mkdirSync as mkdirSync10, rmSync, renameSync as renameSync2 } from "fs";
12876
13405
  import { homedir as homedir9 } from "os";
12877
- import { dirname as dirname10, join as join21 } from "path";
13406
+ import { dirname as dirname11, join as join21 } from "path";
12878
13407
  import { setTimeout as sleep4 } from "timers/promises";
12879
13408
  init_util();
12880
13409
  init_ui();
@@ -12928,8 +13457,8 @@ function buildResetCommand() {
12928
13457
  rmSync(paths.root, { recursive: true, force: true });
12929
13458
  } else {
12930
13459
  if (!archivePath) throw new Error("internal: archivePath missing");
12931
- if (!existsSync18(dirname10(archivePath))) {
12932
- mkdirSync10(dirname10(archivePath), { recursive: true });
13460
+ if (!existsSync18(dirname11(archivePath))) {
13461
+ mkdirSync10(dirname11(archivePath), { recursive: true });
12933
13462
  }
12934
13463
  renameSync2(paths.root, archivePath);
12935
13464
  }
@@ -12939,8 +13468,8 @@ function buildResetCommand() {
12939
13468
  rmSync(assetSyncRoot, { recursive: true, force: true });
12940
13469
  } else {
12941
13470
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
12942
- if (!existsSync18(dirname10(assetSyncArchivePath))) {
12943
- mkdirSync10(dirname10(assetSyncArchivePath), { recursive: true });
13471
+ if (!existsSync18(dirname11(assetSyncArchivePath))) {
13472
+ mkdirSync10(dirname11(assetSyncArchivePath), { recursive: true });
12944
13473
  }
12945
13474
  renameSync2(assetSyncRoot, assetSyncArchivePath);
12946
13475
  }
@@ -12950,8 +13479,8 @@ function buildResetCommand() {
12950
13479
  rmSync(hermesHome, { recursive: true, force: true });
12951
13480
  } else {
12952
13481
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
12953
- if (!existsSync18(dirname10(hermesArchivePath))) {
12954
- mkdirSync10(dirname10(hermesArchivePath), { recursive: true });
13482
+ if (!existsSync18(dirname11(hermesArchivePath))) {
13483
+ mkdirSync10(dirname11(hermesArchivePath), { recursive: true });
12955
13484
  }
12956
13485
  renameSync2(hermesHome, hermesArchivePath);
12957
13486
  }
@@ -14283,7 +14812,7 @@ async function readResponseError(res) {
14283
14812
 
14284
14813
  // src/cli/index.ts
14285
14814
  init_ui();
14286
- var VERSION = "2.0.0";
14815
+ var VERSION = "2.0.1";
14287
14816
  function buildProgram() {
14288
14817
  const program = new Command20("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
14289
14818
  program.addCommand(buildBannerCommand());
@@ -14341,6 +14870,7 @@ export {
14341
14870
  currentSchemaVersion,
14342
14871
  deriveWsUrl,
14343
14872
  envelope,
14873
+ extractHttpUrls,
14344
14874
  getRoleTemplate,
14345
14875
  handleAgentMessageDispatch,
14346
14876
  handleDispatch,