@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/cli.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"
@@ -3928,8 +3929,11 @@ var import_node_path11 = require("path");
3928
3929
 
3929
3930
  // src/asset-cache.ts
3930
3931
  var import_node_crypto = require("crypto");
3932
+ var import_node_dns = require("dns");
3931
3933
  var import_node_fs7 = require("fs");
3934
+ var import_node_net = require("net");
3932
3935
  var import_node_path8 = require("path");
3936
+ var import_undici = require("undici");
3933
3937
  function rowToCached(row) {
3934
3938
  return {
3935
3939
  contentHash: row.content_hash,
@@ -4050,6 +4054,73 @@ var AssetCache = class {
4050
4054
  pin: false
4051
4055
  };
4052
4056
  }
4057
+ /**
4058
+ * Fetch a public http(s) URL, content-hash dedup, cache locally.
4059
+ *
4060
+ * Mirrors `getOrFetch` (cache key = sha256 of body bytes), but the input
4061
+ * is a URL whose hash we don't know up front. The fetcher applies:
4062
+ *
4063
+ * - SSRF guard (resolve hostname, reject loopback / RFC1918 / link-local
4064
+ * / cloud-metadata / IPv6 ULA + link-local). Cross-host redirects
4065
+ * re-validate the new host before each hop.
4066
+ * - manual redirect handling (max 3 hops).
4067
+ * - hard timeout (default 15 s, override via env
4068
+ * `PRISMER_URL_FETCH_TIMEOUT_MS`).
4069
+ * - streaming body read with abort-at-limit (default 5 MiB, override
4070
+ * via env `PRISMER_URL_FETCH_MAX_BYTES`). Truncated bodies are NOT
4071
+ * cached.
4072
+ * - non-2xx → throws (caller should leave the URL in place and emit
4073
+ * an `error` observation).
4074
+ *
4075
+ * On success returns { cached, finalUrl, durationMs }; the caller can
4076
+ * pin / unpin the hash like any other asset.
4077
+ */
4078
+ async getOrFetchUrl(url, opts) {
4079
+ const started = Date.now();
4080
+ const maxBytes = opts?.maxBytes ?? defaultMaxBytes();
4081
+ const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs();
4082
+ const maxRedirects = opts?.maxRedirects ?? 3;
4083
+ const userAgent = opts?.userAgent ?? "prismer-daemon";
4084
+ const { body, finalUrl, mime } = await fetchUrlWithGuards(url, {
4085
+ signal: opts?.signal,
4086
+ maxBytes,
4087
+ timeoutMs,
4088
+ maxRedirects,
4089
+ userAgent
4090
+ });
4091
+ const hash = sha256(body);
4092
+ const existing = this.get(hash);
4093
+ if (existing) {
4094
+ return { cached: existing, finalUrl, durationMs: Date.now() - started };
4095
+ }
4096
+ const localPath = this.pathFor(hash);
4097
+ if (!(0, import_node_fs7.existsSync)((0, import_node_path8.dirname)(localPath))) {
4098
+ (0, import_node_fs7.mkdirSync)((0, import_node_path8.dirname)(localPath), { recursive: true });
4099
+ }
4100
+ const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4101
+ (0, import_node_fs7.writeFileSync)(tmpPath, body);
4102
+ (0, import_node_fs7.renameSync)(tmpPath, localPath);
4103
+ const now = Date.now();
4104
+ this.db.prepare(
4105
+ `INSERT OR REPLACE INTO cached_assets
4106
+ (content_hash, size_bytes, mime, local_path, fetched_at, last_used_at, pin)
4107
+ VALUES (?, ?, ?, ?, ?, ?, 0)`
4108
+ ).run(hash, body.length, mime, localPath, now, now);
4109
+ this.evictIfOver();
4110
+ return {
4111
+ cached: {
4112
+ contentHash: hash,
4113
+ sizeBytes: body.length,
4114
+ mime,
4115
+ localPath,
4116
+ fetchedAt: now,
4117
+ lastUsedAt: now,
4118
+ pin: false
4119
+ },
4120
+ finalUrl,
4121
+ durationMs: now - started
4122
+ };
4123
+ }
4053
4124
  /** Insert an already-on-disk asset into the cache (e.g., asset just produced by adapter). */
4054
4125
  registerLocal(hash, localPath, mime) {
4055
4126
  const actualHash = sha256(readFileBuffer(localPath));
@@ -4101,6 +4172,184 @@ function sha256(buf) {
4101
4172
  function readFileBuffer(path9) {
4102
4173
  return (0, import_node_fs7.readFileSync)(path9);
4103
4174
  }
4175
+ var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
4176
+ var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
4177
+ function defaultMaxBytes() {
4178
+ const v = Number(process.env.PRISMER_URL_FETCH_MAX_BYTES);
4179
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_MAX_BYTES;
4180
+ }
4181
+ function defaultTimeoutMs() {
4182
+ const v = Number(process.env.PRISMER_URL_FETCH_TIMEOUT_MS);
4183
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_TIMEOUT_MS;
4184
+ }
4185
+ var urlFetchDeps = {};
4186
+ function activeFetch() {
4187
+ return urlFetchDeps.fetch ?? globalThis.fetch;
4188
+ }
4189
+ async function activeResolveHostname(host) {
4190
+ if (urlFetchDeps.resolveHostname) return urlFetchDeps.resolveHostname(host);
4191
+ if ((0, import_node_net.isIP)(host)) return [host];
4192
+ const res = await import_node_dns.promises.lookup(host, { all: true, verbatim: true });
4193
+ return res.map((r) => r.address);
4194
+ }
4195
+ function isForbiddenIp(ip) {
4196
+ if ((0, import_node_net.isIPv4)(ip)) {
4197
+ const parts = ip.split(".").map((n) => Number(n));
4198
+ if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) {
4199
+ return true;
4200
+ }
4201
+ const [a = 0, b = 0] = parts;
4202
+ if (a === 127) return true;
4203
+ if (a === 10) return true;
4204
+ if (a === 172 && b >= 16 && b <= 31) return true;
4205
+ if (a === 192 && b === 168) return true;
4206
+ if (a === 169 && b === 254) return true;
4207
+ if (a === 0) return true;
4208
+ return false;
4209
+ }
4210
+ if ((0, import_node_net.isIPv6)(ip)) {
4211
+ const lower = ip.toLowerCase();
4212
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return true;
4213
+ if (lower === "::" || lower === "0:0:0:0:0:0:0:0") return true;
4214
+ if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true;
4215
+ if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;
4216
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(lower);
4217
+ if (mapped) return isForbiddenIp(mapped[1]);
4218
+ return false;
4219
+ }
4220
+ return true;
4221
+ }
4222
+ async function assertHostAllowed(hostname5) {
4223
+ let ips;
4224
+ try {
4225
+ ips = await activeResolveHostname(hostname5);
4226
+ } catch (err) {
4227
+ throw new Error(`hostname resolution failed for ${hostname5}: ${err.message}`);
4228
+ }
4229
+ if (ips.length === 0) {
4230
+ throw new Error(`no IPs resolved for ${hostname5}`);
4231
+ }
4232
+ for (const ip of ips) {
4233
+ if (isForbiddenIp(ip)) {
4234
+ throw new Error(`SSRF guard: ${hostname5} \u2192 ${ip} is in a forbidden range`);
4235
+ }
4236
+ }
4237
+ return ips[0];
4238
+ }
4239
+ async function fetchUrlWithGuards(rawUrl, opts) {
4240
+ let currentUrl;
4241
+ try {
4242
+ currentUrl = new URL(rawUrl);
4243
+ } catch {
4244
+ throw new Error(`invalid URL: ${rawUrl}`);
4245
+ }
4246
+ for (let hop = 0; hop <= opts.maxRedirects; hop += 1) {
4247
+ if (currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") {
4248
+ throw new Error(`unsupported scheme: ${currentUrl.protocol}`);
4249
+ }
4250
+ const pinnedIp = await assertHostAllowed(currentUrl.hostname);
4251
+ const controller = new AbortController();
4252
+ const timer = setTimeout(() => controller.abort(new Error("timeout")), opts.timeoutMs);
4253
+ if (opts.signal) {
4254
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
4255
+ else opts.signal.addEventListener("abort", () => controller.abort(opts.signal.reason), { once: true });
4256
+ }
4257
+ const usingMockedFetch = urlFetchDeps.fetch !== void 0;
4258
+ const dispatcher = usingMockedFetch ? void 0 : new import_undici.Agent({
4259
+ connect: {
4260
+ lookup: (_hostname, _options, cb) => {
4261
+ cb(null, pinnedIp, (0, import_node_net.isIPv6)(pinnedIp) ? 6 : 4);
4262
+ }
4263
+ }
4264
+ });
4265
+ try {
4266
+ let res;
4267
+ try {
4268
+ res = await activeFetch()(currentUrl, {
4269
+ method: "GET",
4270
+ redirect: "manual",
4271
+ headers: { "User-Agent": opts.userAgent, Accept: "*/*" },
4272
+ signal: controller.signal,
4273
+ // `dispatcher` is undici-specific; Node's built-in fetch (which
4274
+ // IS undici under the hood) accepts it via this option.
4275
+ ...dispatcher ? { dispatcher } : {}
4276
+ });
4277
+ } catch (err) {
4278
+ clearTimeout(timer);
4279
+ throw new Error(`fetch failed: ${err.message}`);
4280
+ }
4281
+ if (res.status >= 300 && res.status < 400) {
4282
+ clearTimeout(timer);
4283
+ const loc = res.headers.get("location");
4284
+ if (!loc) throw new Error(`redirect ${res.status} with no Location header`);
4285
+ try {
4286
+ currentUrl = new URL(loc, currentUrl);
4287
+ } catch {
4288
+ throw new Error(`invalid redirect Location: ${loc}`);
4289
+ }
4290
+ try {
4291
+ await res.arrayBuffer();
4292
+ } catch {
4293
+ }
4294
+ if (hop === opts.maxRedirects) {
4295
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
4296
+ }
4297
+ continue;
4298
+ }
4299
+ if (!res.ok) {
4300
+ clearTimeout(timer);
4301
+ throw new Error(`HTTP ${res.status}`);
4302
+ }
4303
+ const contentLength = Number(res.headers.get("content-length"));
4304
+ if (Number.isFinite(contentLength) && contentLength > opts.maxBytes) {
4305
+ clearTimeout(timer);
4306
+ controller.abort(new Error("content-length exceeds limit"));
4307
+ throw new Error(`body too large: content-length=${contentLength} > ${opts.maxBytes}`);
4308
+ }
4309
+ const chunks = [];
4310
+ let total = 0;
4311
+ const reader = res.body?.getReader();
4312
+ try {
4313
+ if (reader) {
4314
+ for (; ; ) {
4315
+ const { done, value } = await reader.read();
4316
+ if (done) break;
4317
+ if (value) {
4318
+ total += value.byteLength;
4319
+ if (total > opts.maxBytes) {
4320
+ controller.abort(new Error("body exceeds limit"));
4321
+ try {
4322
+ await reader.cancel();
4323
+ } catch {
4324
+ }
4325
+ throw new Error(`body too large: read ${total} > ${opts.maxBytes}`);
4326
+ }
4327
+ chunks.push(value);
4328
+ }
4329
+ }
4330
+ } else {
4331
+ const ab = await res.arrayBuffer();
4332
+ if (ab.byteLength > opts.maxBytes) {
4333
+ throw new Error(`body too large: ${ab.byteLength} > ${opts.maxBytes}`);
4334
+ }
4335
+ chunks.push(new Uint8Array(ab));
4336
+ total = ab.byteLength;
4337
+ }
4338
+ } finally {
4339
+ clearTimeout(timer);
4340
+ }
4341
+ const body = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), total);
4342
+ return {
4343
+ body,
4344
+ finalUrl: currentUrl.toString(),
4345
+ mime: res.headers.get("content-type")
4346
+ };
4347
+ } finally {
4348
+ await dispatcher?.close().catch(() => void 0);
4349
+ }
4350
+ }
4351
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
4352
+ }
4104
4353
 
4105
4354
  // src/daemon/asset/metadata-index.ts
4106
4355
  var import_node_fs8 = require("fs");
@@ -4462,11 +4711,11 @@ function buildAssetCommand() {
4462
4711
  }
4463
4712
  printAssetDetail("Asset", assetId, body);
4464
4713
  }, { code: "asset_get_failed" }));
4465
- 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) => {
4714
+ 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) => {
4466
4715
  const cloud = mkCloud();
4467
4716
  const query = new URLSearchParams();
4468
4717
  if (opts.workspaceId) query.set("wsId", opts.workspaceId);
4469
- const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2564)}${query.toString() ? `?${query.toString()}` : ""}`;
4718
+ const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2563)}${query.toString() ? `?${query.toString()}` : ""}`;
4470
4719
  const res = await cloud.request("GET", path9);
4471
4720
  if (!res.ok) exitWithError(`asset by-hash failed (${res.status}): ${res.error?.message ?? "request failed"}`, { code: res.error?.code ?? "asset_by_hash_failed" });
4472
4721
  const body = res.data;
@@ -4477,7 +4726,7 @@ function buildAssetCommand() {
4477
4726
  printJson(body ?? null);
4478
4727
  return;
4479
4728
  }
4480
- printAssetDetail("Asset hash", sha2564, body);
4729
+ printAssetDetail("Asset hash", sha2563, body);
4481
4730
  }, { code: "asset_by_hash_failed" }));
4482
4731
  return cmd;
4483
4732
  }
@@ -5619,6 +5868,8 @@ var SyncWorker = class extends import_node_events.EventEmitter {
5619
5868
  // src/uri-resolver.ts
5620
5869
  var URI_REGEX_WORKSPACE = /prismer:\/\/workspace\/([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
5621
5870
  var URI_REGEX_LEGACY = /prismer:\/\/(?!workspace\/)([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
5871
+ var HTTP_URL_REGEX = /https?:\/\/[^\s<>"'`]+/g;
5872
+ var URL_TRAILING_PUNCT_RE = /[,.;:!?)\]}'"]+$/;
5622
5873
  function parseUris(text) {
5623
5874
  if (!text) return [];
5624
5875
  const out = [];
@@ -5681,12 +5932,20 @@ var UriResolver = class {
5681
5932
  return cached.localPath;
5682
5933
  }
5683
5934
  /**
5684
- * Walk a string, replace every `prismer://(asset|file)/...` with `file://<localPath>`.
5685
- * Unrecognized URIs pass through. Returns rewritten text + the list of pinned hashes.
5935
+ * Walk a string, replace every `prismer://(asset|file)/...` and
5936
+ * `https://…` / `http://…` URL with `file://<localPath>`.
5937
+ *
5938
+ * Unrecognized URIs pass through unchanged. Returns rewritten text, the
5939
+ * list of pinned hashes, and one observation per http(s) URL the resolver
5940
+ * attempted (success + error both surface) so the dispatch can include
5941
+ * them in `reply.assetObservability`.
5942
+ *
5943
+ * `urlCache` lets the caller dedupe URL fetches across multiple rewrite
5944
+ * calls within a single dispatch (e.g. prompt + each context entry).
5945
+ * Pass the same Map instance to every rewrite() / rewriteAll() call.
5686
5946
  */
5687
5947
  async rewrite(text, opts) {
5688
5948
  const uris = parseUris(text);
5689
- if (uris.length === 0) return { text, resolvedHashes: [] };
5690
5949
  const replacements = /* @__PURE__ */ new Map();
5691
5950
  const resolvedHashes = [];
5692
5951
  for (const u of uris) {
@@ -5709,6 +5968,46 @@ var UriResolver = class {
5709
5968
  console.warn(`[uri-resolver] failed to resolve ${u.raw}: ${err.message}`);
5710
5969
  }
5711
5970
  }
5971
+ if (opts?.fetchUrls !== false) {
5972
+ const urlCache = opts?.urlCache;
5973
+ const urls = extractHttpUrls(text);
5974
+ for (const original of urls) {
5975
+ try {
5976
+ let resolution = urlCache?.get(original);
5977
+ if (!resolution) {
5978
+ const { cached, finalUrl, durationMs } = await this.assetCache.getOrFetchUrl(original, {
5979
+ signal: opts?.signal
5980
+ });
5981
+ resolution = { hash: cached.contentHash, localPath: cached.localPath, sizeBytes: cached.sizeBytes, mime: cached.mime, finalUrl, durationMs };
5982
+ urlCache?.set(original, resolution);
5983
+ opts?.urlObservations?.push({
5984
+ contentHash: cached.contentHash,
5985
+ mime: cached.mime,
5986
+ sizeBytes: cached.sizeBytes,
5987
+ strategy: "fetched-https",
5988
+ originalUrl: original,
5989
+ finalUrl,
5990
+ durationMs
5991
+ });
5992
+ }
5993
+ replacements.set(original, `file://${resolution.localPath}`);
5994
+ resolvedHashes.push(resolution.hash);
5995
+ if (opts?.pin) this.assetCache.pin(resolution.hash);
5996
+ } catch (err) {
5997
+ const message = err.message;
5998
+ console.warn(`[uri-resolver] failed to fetch ${original}: ${message}`);
5999
+ opts?.urlObservations?.push({
6000
+ contentHash: "",
6001
+ mime: null,
6002
+ sizeBytes: null,
6003
+ strategy: "error",
6004
+ originalUrl: original,
6005
+ error: message
6006
+ });
6007
+ }
6008
+ }
6009
+ }
6010
+ if (replacements.size === 0) return { text, resolvedHashes };
5712
6011
  let rewritten = text;
5713
6012
  for (const [raw, sub] of replacements) {
5714
6013
  rewritten = rewritten.split(raw).join(sub);
@@ -5746,6 +6045,27 @@ var UriResolver = class {
5746
6045
  function dedupe(xs) {
5747
6046
  return Array.from(new Set(xs));
5748
6047
  }
6048
+ function extractHttpUrls(text) {
6049
+ if (!text) return [];
6050
+ const seen = /* @__PURE__ */ new Set();
6051
+ const out = [];
6052
+ for (const m of text.matchAll(HTTP_URL_REGEX)) {
6053
+ let url = m[0];
6054
+ const punct = URL_TRAILING_PUNCT_RE.exec(url);
6055
+ if (punct) url = url.slice(0, -punct[0].length);
6056
+ if (!url) continue;
6057
+ try {
6058
+ const parsed = new URL(url);
6059
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue;
6060
+ } catch {
6061
+ continue;
6062
+ }
6063
+ if (seen.has(url)) continue;
6064
+ seen.add(url);
6065
+ out.push(url);
6066
+ }
6067
+ return out;
6068
+ }
5749
6069
 
5750
6070
  // src/lib/logger.ts
5751
6071
  var LEVEL_ORDER = {
@@ -5834,29 +6154,141 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
5834
6154
  for (const entry of entries) {
5835
6155
  const slug = sanitizeSlug(trimmedStringFrom(entry.skill?.slug) ?? trimmedStringFrom(entry.slug));
5836
6156
  const skillId = trimmedStringFrom(entry.skill?.id);
5837
- const content = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
5838
- if (!slug || !content) {
6157
+ if (!slug) {
5839
6158
  skipped++;
5840
- if (slug && !content) {
5841
- process.stderr.write(`[daemon] skill sync skipped ${slug}: missing content
5842
- `);
5843
- await ackSkillSync(cloud, agentImUserId, { slug, error: "missing content" }, signal);
6159
+ continue;
6160
+ }
6161
+ let files = null;
6162
+ const manifestRaw = entry.skill?.contentManifest;
6163
+ if (typeof manifestRaw === "string" && manifestRaw.trim()) {
6164
+ files = parseManifest(manifestRaw, slug);
6165
+ }
6166
+ if (!files) {
6167
+ const legacyContent = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
6168
+ if (legacyContent) {
6169
+ const buf = Buffer.from(legacyContent, "utf8");
6170
+ files = [
6171
+ {
6172
+ path: "SKILL.md",
6173
+ size: buf.byteLength,
6174
+ sha256: sha256Buffer(buf),
6175
+ inline: true,
6176
+ content: buf.toString("base64")
6177
+ }
6178
+ ];
5844
6179
  }
6180
+ }
6181
+ if (!files || files.length === 0) {
6182
+ skipped++;
6183
+ process.stderr.write(`[daemon] skill sync skipped ${slug}: no manifest or content
6184
+ `);
6185
+ await ackSkillSync(cloud, agentImUserId, { skillId, slug, error: "missing content" }, signal);
5845
6186
  continue;
5846
6187
  }
5847
- const revision = sha2562(content);
5848
6188
  const skillDir = (0, import_node_path12.join)(skillsRoot, slug);
5849
6189
  await import_node_fs11.promises.mkdir(skillDir, { recursive: true });
5850
- const target = (0, import_node_path12.join)(skillDir, "SKILL.md");
5851
- const existing = await import_node_fs11.promises.readFile(target, "utf8").catch(() => null);
5852
- if (existing != null && sha2562(existing) === revision) {
5853
- unchanged++;
5854
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
6190
+ const localFiles = await walkLocalDir(skillDir);
6191
+ let dirty = false;
6192
+ let perFileFailures = 0;
6193
+ for (const file of files) {
6194
+ if (!isSafeRelativePath(file.path)) {
6195
+ process.stderr.write(`[daemon] skill sync ${slug}: skipping suspicious path "${file.path}"
6196
+ `);
6197
+ perFileFailures++;
6198
+ continue;
6199
+ }
6200
+ const targetPath = (0, import_node_path12.join)(skillDir, file.path);
6201
+ const normalizedTarget = targetPath + (targetPath.endsWith(import_node_path12.sep) ? "" : "");
6202
+ if (normalizedTarget !== skillDir && !normalizedTarget.startsWith(skillDir + import_node_path12.sep)) {
6203
+ process.stderr.write(`[daemon] skill sync ${slug}: path escapes skillDir "${file.path}"
6204
+ `);
6205
+ perFileFailures++;
6206
+ continue;
6207
+ }
6208
+ const existingHash = localFiles.get(file.path);
6209
+ if (existingHash === file.sha256) {
6210
+ localFiles.delete(file.path);
6211
+ continue;
6212
+ }
6213
+ let bytes = null;
6214
+ try {
6215
+ if (file.inline !== false && typeof file.content === "string") {
6216
+ bytes = Buffer.from(file.content, "base64");
6217
+ } else if (typeof file.url === "string" && file.url) {
6218
+ bytes = await downloadUrl(file.url, signal);
6219
+ } else {
6220
+ process.stderr.write(
6221
+ `[daemon] skill sync ${slug}: file ${file.path} has neither inline content nor url
6222
+ `
6223
+ );
6224
+ perFileFailures++;
6225
+ continue;
6226
+ }
6227
+ } catch (err) {
6228
+ process.stderr.write(
6229
+ `[daemon] skill sync ${slug}: failed to fetch ${file.path}: ${err.message}
6230
+ `
6231
+ );
6232
+ perFileFailures++;
6233
+ continue;
6234
+ }
6235
+ const downloadedHash = sha256Buffer(bytes);
6236
+ if (downloadedHash !== file.sha256) {
6237
+ process.stderr.write(
6238
+ `[daemon] skill sync ${slug}: hash mismatch for ${file.path} (expected ${file.sha256}, got ${downloadedHash})
6239
+ `
6240
+ );
6241
+ perFileFailures++;
6242
+ continue;
6243
+ }
6244
+ await import_node_fs11.promises.mkdir((0, import_node_path12.dirname)(targetPath), { recursive: true });
6245
+ await import_node_fs11.promises.writeFile(targetPath, bytes);
6246
+ localFiles.delete(file.path);
6247
+ dirty = true;
6248
+ }
6249
+ for (const orphan of localFiles.keys()) {
6250
+ try {
6251
+ await import_node_fs11.promises.unlink((0, import_node_path12.join)(skillDir, orphan));
6252
+ dirty = true;
6253
+ } catch (err) {
6254
+ if (err.code !== "ENOENT") {
6255
+ process.stderr.write(
6256
+ `[daemon] skill sync ${slug}: failed to remove orphan ${orphan}: ${err.message}
6257
+ `
6258
+ );
6259
+ }
6260
+ }
6261
+ }
6262
+ if (perFileFailures > 0) {
6263
+ skipped++;
6264
+ await ackSkillSync(
6265
+ cloud,
6266
+ agentImUserId,
6267
+ { skillId, slug, error: `${perFileFailures} file(s) failed` },
6268
+ signal
6269
+ );
5855
6270
  continue;
5856
6271
  }
5857
- await import_node_fs11.promises.writeFile(target, content, "utf8");
5858
- synced++;
5859
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
6272
+ const localMerkle = computeMerkle(files);
6273
+ const declaredRevision = trimmedStringFrom(entry.skill?.contentManifestRevision);
6274
+ const revision = declaredRevision ?? localMerkle;
6275
+ if (declaredRevision && declaredRevision !== localMerkle) {
6276
+ process.stderr.write(
6277
+ `[daemon] skill sync ${slug}: declared revision ${declaredRevision} != computed ${localMerkle}; using computed
6278
+ `
6279
+ );
6280
+ }
6281
+ if (dirty) {
6282
+ synced++;
6283
+ } else {
6284
+ unchanged++;
6285
+ }
6286
+ await ackSkillSync(
6287
+ cloud,
6288
+ agentImUserId,
6289
+ { skillId, slug, revision: declaredRevision === localMerkle ? declaredRevision : localMerkle },
6290
+ signal
6291
+ );
5860
6292
  }
5861
6293
  return { synced, skipped, unchanged };
5862
6294
  }
@@ -5874,9 +6306,96 @@ function resolveSkillsRoot(profile) {
5874
6306
  }
5875
6307
  return null;
5876
6308
  }
5877
- function sha2562(value) {
6309
+ function sha256Buffer(value) {
5878
6310
  return (0, import_node_crypto3.createHash)("sha256").update(value).digest("hex");
5879
6311
  }
6312
+ function computeMerkle(files) {
6313
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
6314
+ const lines = sorted.map((f) => `${f.path}:${f.sha256}`).join("\n");
6315
+ return (0, import_node_crypto3.createHash)("sha256").update(lines).digest("hex");
6316
+ }
6317
+ function parseManifest(raw, slug) {
6318
+ try {
6319
+ const parsed = JSON.parse(raw);
6320
+ const arr = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.files) ? parsed.files : null;
6321
+ if (!Array.isArray(arr)) {
6322
+ process.stderr.write(`[daemon] skill sync ${slug}: contentManifest is not an array
6323
+ `);
6324
+ return null;
6325
+ }
6326
+ const files = [];
6327
+ for (const item of arr) {
6328
+ if (!item || typeof item !== "object") continue;
6329
+ const rec = item;
6330
+ const path9 = typeof rec.path === "string" ? rec.path : null;
6331
+ const sha2563 = typeof rec.sha256 === "string" ? rec.sha256 : null;
6332
+ const size = typeof rec.size === "number" && Number.isFinite(rec.size) && rec.size >= 0 ? Math.floor(rec.size) : null;
6333
+ if (!path9 || !sha2563 || size === null) continue;
6334
+ const content = typeof rec.content === "string" ? rec.content : void 0;
6335
+ const url = typeof rec.url === "string" ? rec.url : void 0;
6336
+ const inline = typeof rec.inline === "boolean" ? rec.inline : content !== void 0 ? true : void 0;
6337
+ files.push({ path: path9, size, sha256: sha2563, inline, content, url });
6338
+ }
6339
+ return files;
6340
+ } catch (err) {
6341
+ process.stderr.write(
6342
+ `[daemon] skill sync ${slug}: invalid contentManifest JSON: ${err.message}
6343
+ `
6344
+ );
6345
+ return null;
6346
+ }
6347
+ }
6348
+ function isSafeRelativePath(p) {
6349
+ if (typeof p !== "string" || !p) return false;
6350
+ if (p.length > 512) return false;
6351
+ if (p.includes("\0")) return false;
6352
+ if (p.startsWith("/") || p.startsWith("\\")) return false;
6353
+ if (/^[A-Za-z]:[\\/]/.test(p)) return false;
6354
+ const segs = p.replace(/\\/g, "/").split("/");
6355
+ for (const seg of segs) {
6356
+ if (seg === "" || seg === "." || seg === "..") return false;
6357
+ }
6358
+ return true;
6359
+ }
6360
+ async function walkLocalDir(root) {
6361
+ const out = /* @__PURE__ */ new Map();
6362
+ async function walk2(dir) {
6363
+ let entries;
6364
+ try {
6365
+ entries = await import_node_fs11.promises.readdir(dir, { withFileTypes: true });
6366
+ } catch (err) {
6367
+ if (err.code === "ENOENT") return;
6368
+ throw err;
6369
+ }
6370
+ for (const ent of entries) {
6371
+ const full = (0, import_node_path12.join)(dir, ent.name);
6372
+ if (ent.isDirectory()) {
6373
+ await walk2(full);
6374
+ } else if (ent.isFile()) {
6375
+ try {
6376
+ const buf = await import_node_fs11.promises.readFile(full);
6377
+ const rel = (0, import_node_path12.relative)(root, full).split(import_node_path12.sep).join("/");
6378
+ out.set(rel, sha256Buffer(buf));
6379
+ } catch {
6380
+ }
6381
+ }
6382
+ }
6383
+ }
6384
+ await walk2(root);
6385
+ return out;
6386
+ }
6387
+ async function downloadUrl(url, signal) {
6388
+ const lower = url.toLowerCase();
6389
+ if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
6390
+ throw new Error(`unsupported url scheme: ${url.slice(0, 32)}`);
6391
+ }
6392
+ const res = await fetch(url, { signal });
6393
+ if (!res.ok) {
6394
+ throw new Error(`fetch ${url} failed: HTTP ${res.status}`);
6395
+ }
6396
+ const ab = await res.arrayBuffer();
6397
+ return Buffer.from(ab);
6398
+ }
5880
6399
  function normalizeInstalledSkills(data) {
5881
6400
  if (Array.isArray(data)) return data.filter(isInstalledSkillEntry);
5882
6401
  if (!data || typeof data !== "object" || Array.isArray(data)) return [];
@@ -5996,12 +6515,22 @@ async function handleDispatch(payload, requestId, deps) {
5996
6515
  payload.prompt = hashRefResult.text;
5997
6516
  }
5998
6517
  }
5999
- const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, { pin: true });
6518
+ const urlCache = /* @__PURE__ */ new Map();
6519
+ const urlObservations = [];
6520
+ const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, {
6521
+ pin: true,
6522
+ urlCache,
6523
+ urlObservations
6524
+ });
6000
6525
  resolvedHashes.push(...rewrittenPrompt.resolvedHashes);
6001
6526
  let rewrittenContext = [];
6002
6527
  if (payload.context && payload.context.length > 0) {
6003
6528
  const contents = payload.context.map((e) => e.content);
6004
- const r = await deps.uriResolver.rewriteAll(contents, { pin: true });
6529
+ const r = await deps.uriResolver.rewriteAll(contents, {
6530
+ pin: true,
6531
+ urlCache,
6532
+ urlObservations
6533
+ });
6005
6534
  resolvedHashes.push(...r.resolvedHashes);
6006
6535
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
6007
6536
  }
@@ -6121,7 +6650,7 @@ async function handleDispatch(payload, requestId, deps) {
6121
6650
  } : result.error,
6122
6651
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
6123
6652
  metrics: result.metrics,
6124
- ...assetResolution.observability.length > 0 ? { assetObservability: assetResolution.observability } : {}
6653
+ ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
6125
6654
  };
6126
6655
  await writeBridgeMetadata(payload.taskId, deps.cloud, result.metadata, deps.signal);
6127
6656
  await writeObservabilityMetadata(
@@ -7288,10 +7817,10 @@ async function walkAndDigest(root, current) {
7288
7817
  }
7289
7818
  if (!st.isFile()) continue;
7290
7819
  const buf = await import_node_fs13.promises.readFile(full);
7291
- const sha2564 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
7820
+ const sha2563 = (0, import_node_crypto4.createHash)("sha256").update(buf).digest("hex");
7292
7821
  out.push({
7293
7822
  path: rel,
7294
- sha256: sha2564,
7823
+ sha256: sha2563,
7295
7824
  sizeBytes: st.size,
7296
7825
  mtime: Math.floor(st.mtimeMs)
7297
7826
  });
@@ -7498,7 +8027,7 @@ var MemoryStore = class {
7498
8027
  }
7499
8028
  const db = this.requireDb();
7500
8029
  const now = Date.now();
7501
- const contentHash = sha2563(input.content);
8030
+ const contentHash = sha2562(input.content);
7502
8031
  const payload = sealPlaintext(input.content);
7503
8032
  if (payload.kind !== "inline") {
7504
8033
  throw new Error("MemoryStore.write: non-inline payload not yet supported in phase-0");
@@ -7692,7 +8221,7 @@ var MemoryStore = class {
7692
8221
  };
7693
8222
  }
7694
8223
  };
7695
- function sha2563(s) {
8224
+ function sha2562(s) {
7696
8225
  return (0, import_node_crypto5.createHash)("sha256").update(s, "utf8").digest("hex");
7697
8226
  }
7698
8227
 
@@ -14178,7 +14707,7 @@ async function readResponseError(res) {
14178
14707
 
14179
14708
  // src/cli/index.ts
14180
14709
  init_ui();
14181
- var VERSION = "2.0.0";
14710
+ var VERSION = "2.0.1";
14182
14711
  function buildProgram() {
14183
14712
  const program = new import_commander20.Command("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
14184
14713
  program.addCommand(buildBannerCommand());