@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.js CHANGED
@@ -443,7 +443,7 @@ var require_package = __commonJS({
443
443
  "package.json"(exports, module) {
444
444
  module.exports = {
445
445
  name: "@prismer/runtime",
446
- version: "2.0.0",
446
+ version: "2.0.1",
447
447
  description: "Prismer Cloud daemon runtime \u2014 TS-only adapter host for hosted IM agents",
448
448
  type: "module",
449
449
  main: "dist/index.js",
@@ -480,6 +480,7 @@ var require_package = __commonJS({
480
480
  "better-sqlite3": "^11.8.0",
481
481
  commander: "^12.1.0",
482
482
  qrcode: "^1.5.4",
483
+ undici: "^7.24.0",
483
484
  ws: "^8.19.0",
484
485
  yaml: "^2.8.2",
485
486
  zod: "^3.23.8"
@@ -3912,8 +3913,11 @@ import { basename, dirname as dirname7, join as join10, relative } from "path";
3912
3913
 
3913
3914
  // src/asset-cache.ts
3914
3915
  import { createHash } from "crypto";
3916
+ import { promises as dnsp } from "dns";
3915
3917
  import { existsSync as existsSync7, mkdirSync as mkdirSync5, readFileSync as readFileSync6, renameSync, statSync as statSync2, unlinkSync as unlinkSync2, writeFileSync as writeFileSync5 } from "fs";
3918
+ import { isIP, isIPv4, isIPv6 } from "net";
3916
3919
  import { dirname as dirname6, join as join7 } from "path";
3920
+ import { Agent as UndiciAgent } from "undici";
3917
3921
  function rowToCached(row) {
3918
3922
  return {
3919
3923
  contentHash: row.content_hash,
@@ -4034,6 +4038,73 @@ var AssetCache = class {
4034
4038
  pin: false
4035
4039
  };
4036
4040
  }
4041
+ /**
4042
+ * Fetch a public http(s) URL, content-hash dedup, cache locally.
4043
+ *
4044
+ * Mirrors `getOrFetch` (cache key = sha256 of body bytes), but the input
4045
+ * is a URL whose hash we don't know up front. The fetcher applies:
4046
+ *
4047
+ * - SSRF guard (resolve hostname, reject loopback / RFC1918 / link-local
4048
+ * / cloud-metadata / IPv6 ULA + link-local). Cross-host redirects
4049
+ * re-validate the new host before each hop.
4050
+ * - manual redirect handling (max 3 hops).
4051
+ * - hard timeout (default 15 s, override via env
4052
+ * `PRISMER_URL_FETCH_TIMEOUT_MS`).
4053
+ * - streaming body read with abort-at-limit (default 5 MiB, override
4054
+ * via env `PRISMER_URL_FETCH_MAX_BYTES`). Truncated bodies are NOT
4055
+ * cached.
4056
+ * - non-2xx → throws (caller should leave the URL in place and emit
4057
+ * an `error` observation).
4058
+ *
4059
+ * On success returns { cached, finalUrl, durationMs }; the caller can
4060
+ * pin / unpin the hash like any other asset.
4061
+ */
4062
+ async getOrFetchUrl(url, opts) {
4063
+ const started = Date.now();
4064
+ const maxBytes = opts?.maxBytes ?? defaultMaxBytes();
4065
+ const timeoutMs = opts?.timeoutMs ?? defaultTimeoutMs();
4066
+ const maxRedirects = opts?.maxRedirects ?? 3;
4067
+ const userAgent = opts?.userAgent ?? "prismer-daemon";
4068
+ const { body, finalUrl, mime } = await fetchUrlWithGuards(url, {
4069
+ signal: opts?.signal,
4070
+ maxBytes,
4071
+ timeoutMs,
4072
+ maxRedirects,
4073
+ userAgent
4074
+ });
4075
+ const hash = sha256(body);
4076
+ const existing = this.get(hash);
4077
+ if (existing) {
4078
+ return { cached: existing, finalUrl, durationMs: Date.now() - started };
4079
+ }
4080
+ const localPath = this.pathFor(hash);
4081
+ if (!existsSync7(dirname6(localPath))) {
4082
+ mkdirSync5(dirname6(localPath), { recursive: true });
4083
+ }
4084
+ const tmpPath = `${localPath}.${process.pid}.${Date.now()}.tmp`;
4085
+ writeFileSync5(tmpPath, body);
4086
+ renameSync(tmpPath, localPath);
4087
+ const now = Date.now();
4088
+ this.db.prepare(
4089
+ `INSERT OR REPLACE INTO cached_assets
4090
+ (content_hash, size_bytes, mime, local_path, fetched_at, last_used_at, pin)
4091
+ VALUES (?, ?, ?, ?, ?, ?, 0)`
4092
+ ).run(hash, body.length, mime, localPath, now, now);
4093
+ this.evictIfOver();
4094
+ return {
4095
+ cached: {
4096
+ contentHash: hash,
4097
+ sizeBytes: body.length,
4098
+ mime,
4099
+ localPath,
4100
+ fetchedAt: now,
4101
+ lastUsedAt: now,
4102
+ pin: false
4103
+ },
4104
+ finalUrl,
4105
+ durationMs: now - started
4106
+ };
4107
+ }
4037
4108
  /** Insert an already-on-disk asset into the cache (e.g., asset just produced by adapter). */
4038
4109
  registerLocal(hash, localPath, mime) {
4039
4110
  const actualHash = sha256(readFileBuffer(localPath));
@@ -4085,6 +4156,184 @@ function sha256(buf) {
4085
4156
  function readFileBuffer(path9) {
4086
4157
  return readFileSync6(path9);
4087
4158
  }
4159
+ var DEFAULT_URL_FETCH_MAX_BYTES = 5 * 1024 * 1024;
4160
+ var DEFAULT_URL_FETCH_TIMEOUT_MS = 15e3;
4161
+ function defaultMaxBytes() {
4162
+ const v = Number(process.env.PRISMER_URL_FETCH_MAX_BYTES);
4163
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_MAX_BYTES;
4164
+ }
4165
+ function defaultTimeoutMs() {
4166
+ const v = Number(process.env.PRISMER_URL_FETCH_TIMEOUT_MS);
4167
+ return Number.isFinite(v) && v > 0 ? v : DEFAULT_URL_FETCH_TIMEOUT_MS;
4168
+ }
4169
+ var urlFetchDeps = {};
4170
+ function activeFetch() {
4171
+ return urlFetchDeps.fetch ?? globalThis.fetch;
4172
+ }
4173
+ async function activeResolveHostname(host) {
4174
+ if (urlFetchDeps.resolveHostname) return urlFetchDeps.resolveHostname(host);
4175
+ if (isIP(host)) return [host];
4176
+ const res = await dnsp.lookup(host, { all: true, verbatim: true });
4177
+ return res.map((r) => r.address);
4178
+ }
4179
+ function isForbiddenIp(ip) {
4180
+ if (isIPv4(ip)) {
4181
+ const parts = ip.split(".").map((n) => Number(n));
4182
+ if (parts.length !== 4 || parts.some((p) => !Number.isFinite(p) || p < 0 || p > 255)) {
4183
+ return true;
4184
+ }
4185
+ const [a = 0, b = 0] = parts;
4186
+ if (a === 127) return true;
4187
+ if (a === 10) return true;
4188
+ if (a === 172 && b >= 16 && b <= 31) return true;
4189
+ if (a === 192 && b === 168) return true;
4190
+ if (a === 169 && b === 254) return true;
4191
+ if (a === 0) return true;
4192
+ return false;
4193
+ }
4194
+ if (isIPv6(ip)) {
4195
+ const lower = ip.toLowerCase();
4196
+ if (lower === "::1" || lower === "0:0:0:0:0:0:0:1") return true;
4197
+ if (lower === "::" || lower === "0:0:0:0:0:0:0:0") return true;
4198
+ if (/^fe[89ab][0-9a-f]?:/.test(lower)) return true;
4199
+ if (/^f[cd][0-9a-f]{2}:/.test(lower)) return true;
4200
+ const mapped = /^::ffff:(\d+\.\d+\.\d+\.\d+)$/i.exec(lower);
4201
+ if (mapped) return isForbiddenIp(mapped[1]);
4202
+ return false;
4203
+ }
4204
+ return true;
4205
+ }
4206
+ async function assertHostAllowed(hostname5) {
4207
+ let ips;
4208
+ try {
4209
+ ips = await activeResolveHostname(hostname5);
4210
+ } catch (err) {
4211
+ throw new Error(`hostname resolution failed for ${hostname5}: ${err.message}`);
4212
+ }
4213
+ if (ips.length === 0) {
4214
+ throw new Error(`no IPs resolved for ${hostname5}`);
4215
+ }
4216
+ for (const ip of ips) {
4217
+ if (isForbiddenIp(ip)) {
4218
+ throw new Error(`SSRF guard: ${hostname5} \u2192 ${ip} is in a forbidden range`);
4219
+ }
4220
+ }
4221
+ return ips[0];
4222
+ }
4223
+ async function fetchUrlWithGuards(rawUrl, opts) {
4224
+ let currentUrl;
4225
+ try {
4226
+ currentUrl = new URL(rawUrl);
4227
+ } catch {
4228
+ throw new Error(`invalid URL: ${rawUrl}`);
4229
+ }
4230
+ for (let hop = 0; hop <= opts.maxRedirects; hop += 1) {
4231
+ if (currentUrl.protocol !== "http:" && currentUrl.protocol !== "https:") {
4232
+ throw new Error(`unsupported scheme: ${currentUrl.protocol}`);
4233
+ }
4234
+ const pinnedIp = await assertHostAllowed(currentUrl.hostname);
4235
+ const controller = new AbortController();
4236
+ const timer = setTimeout(() => controller.abort(new Error("timeout")), opts.timeoutMs);
4237
+ if (opts.signal) {
4238
+ if (opts.signal.aborted) controller.abort(opts.signal.reason);
4239
+ else opts.signal.addEventListener("abort", () => controller.abort(opts.signal.reason), { once: true });
4240
+ }
4241
+ const usingMockedFetch = urlFetchDeps.fetch !== void 0;
4242
+ const dispatcher = usingMockedFetch ? void 0 : new UndiciAgent({
4243
+ connect: {
4244
+ lookup: (_hostname, _options, cb) => {
4245
+ cb(null, pinnedIp, isIPv6(pinnedIp) ? 6 : 4);
4246
+ }
4247
+ }
4248
+ });
4249
+ try {
4250
+ let res;
4251
+ try {
4252
+ res = await activeFetch()(currentUrl, {
4253
+ method: "GET",
4254
+ redirect: "manual",
4255
+ headers: { "User-Agent": opts.userAgent, Accept: "*/*" },
4256
+ signal: controller.signal,
4257
+ // `dispatcher` is undici-specific; Node's built-in fetch (which
4258
+ // IS undici under the hood) accepts it via this option.
4259
+ ...dispatcher ? { dispatcher } : {}
4260
+ });
4261
+ } catch (err) {
4262
+ clearTimeout(timer);
4263
+ throw new Error(`fetch failed: ${err.message}`);
4264
+ }
4265
+ if (res.status >= 300 && res.status < 400) {
4266
+ clearTimeout(timer);
4267
+ const loc = res.headers.get("location");
4268
+ if (!loc) throw new Error(`redirect ${res.status} with no Location header`);
4269
+ try {
4270
+ currentUrl = new URL(loc, currentUrl);
4271
+ } catch {
4272
+ throw new Error(`invalid redirect Location: ${loc}`);
4273
+ }
4274
+ try {
4275
+ await res.arrayBuffer();
4276
+ } catch {
4277
+ }
4278
+ if (hop === opts.maxRedirects) {
4279
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
4280
+ }
4281
+ continue;
4282
+ }
4283
+ if (!res.ok) {
4284
+ clearTimeout(timer);
4285
+ throw new Error(`HTTP ${res.status}`);
4286
+ }
4287
+ const contentLength = Number(res.headers.get("content-length"));
4288
+ if (Number.isFinite(contentLength) && contentLength > opts.maxBytes) {
4289
+ clearTimeout(timer);
4290
+ controller.abort(new Error("content-length exceeds limit"));
4291
+ throw new Error(`body too large: content-length=${contentLength} > ${opts.maxBytes}`);
4292
+ }
4293
+ const chunks = [];
4294
+ let total = 0;
4295
+ const reader = res.body?.getReader();
4296
+ try {
4297
+ if (reader) {
4298
+ for (; ; ) {
4299
+ const { done, value } = await reader.read();
4300
+ if (done) break;
4301
+ if (value) {
4302
+ total += value.byteLength;
4303
+ if (total > opts.maxBytes) {
4304
+ controller.abort(new Error("body exceeds limit"));
4305
+ try {
4306
+ await reader.cancel();
4307
+ } catch {
4308
+ }
4309
+ throw new Error(`body too large: read ${total} > ${opts.maxBytes}`);
4310
+ }
4311
+ chunks.push(value);
4312
+ }
4313
+ }
4314
+ } else {
4315
+ const ab = await res.arrayBuffer();
4316
+ if (ab.byteLength > opts.maxBytes) {
4317
+ throw new Error(`body too large: ${ab.byteLength} > ${opts.maxBytes}`);
4318
+ }
4319
+ chunks.push(new Uint8Array(ab));
4320
+ total = ab.byteLength;
4321
+ }
4322
+ } finally {
4323
+ clearTimeout(timer);
4324
+ }
4325
+ const body = Buffer.concat(chunks.map((c) => Buffer.from(c.buffer, c.byteOffset, c.byteLength)), total);
4326
+ return {
4327
+ body,
4328
+ finalUrl: currentUrl.toString(),
4329
+ mime: res.headers.get("content-type")
4330
+ };
4331
+ } finally {
4332
+ await dispatcher?.close().catch(() => void 0);
4333
+ }
4334
+ }
4335
+ throw new Error(`too many redirects (>${opts.maxRedirects})`);
4336
+ }
4088
4337
 
4089
4338
  // src/daemon/asset/metadata-index.ts
4090
4339
  import { existsSync as existsSync8, mkdirSync as mkdirSync6, readFileSync as readFileSync7, writeFileSync as writeFileSync6 } from "fs";
@@ -4446,11 +4695,11 @@ function buildAssetCommand() {
4446
4695
  }
4447
4696
  printAssetDetail("Asset", assetId, body);
4448
4697
  }, { code: "asset_get_failed" }));
4449
- 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) => {
4698
+ 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) => {
4450
4699
  const cloud = mkCloud();
4451
4700
  const query = new URLSearchParams();
4452
4701
  if (opts.workspaceId) query.set("wsId", opts.workspaceId);
4453
- const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2564)}${query.toString() ? `?${query.toString()}` : ""}`;
4702
+ const path9 = `/api/im/assets/by-hash/${encodeURIComponent(sha2563)}${query.toString() ? `?${query.toString()}` : ""}`;
4454
4703
  const res = await cloud.request("GET", path9);
4455
4704
  if (!res.ok) exitWithError(`asset by-hash failed (${res.status}): ${res.error?.message ?? "request failed"}`, { code: res.error?.code ?? "asset_by_hash_failed" });
4456
4705
  const body = res.data;
@@ -4461,7 +4710,7 @@ function buildAssetCommand() {
4461
4710
  printJson(body ?? null);
4462
4711
  return;
4463
4712
  }
4464
- printAssetDetail("Asset hash", sha2564, body);
4713
+ printAssetDetail("Asset hash", sha2563, body);
4465
4714
  }, { code: "asset_by_hash_failed" }));
4466
4715
  return cmd;
4467
4716
  }
@@ -5603,6 +5852,8 @@ var SyncWorker = class extends EventEmitter {
5603
5852
  // src/uri-resolver.ts
5604
5853
  var URI_REGEX_WORKSPACE = /prismer:\/\/workspace\/([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
5605
5854
  var URI_REGEX_LEGACY = /prismer:\/\/(?!workspace\/)([^/\s]+)\/(asset|file)\/([^\s)\]]+)/g;
5855
+ var HTTP_URL_REGEX = /https?:\/\/[^\s<>"'`]+/g;
5856
+ var URL_TRAILING_PUNCT_RE = /[,.;:!?)\]}'"]+$/;
5606
5857
  function parseUris(text) {
5607
5858
  if (!text) return [];
5608
5859
  const out = [];
@@ -5665,12 +5916,20 @@ var UriResolver = class {
5665
5916
  return cached.localPath;
5666
5917
  }
5667
5918
  /**
5668
- * Walk a string, replace every `prismer://(asset|file)/...` with `file://<localPath>`.
5669
- * Unrecognized URIs pass through. Returns rewritten text + the list of pinned hashes.
5919
+ * Walk a string, replace every `prismer://(asset|file)/...` and
5920
+ * `https://…` / `http://…` URL with `file://<localPath>`.
5921
+ *
5922
+ * Unrecognized URIs pass through unchanged. Returns rewritten text, the
5923
+ * list of pinned hashes, and one observation per http(s) URL the resolver
5924
+ * attempted (success + error both surface) so the dispatch can include
5925
+ * them in `reply.assetObservability`.
5926
+ *
5927
+ * `urlCache` lets the caller dedupe URL fetches across multiple rewrite
5928
+ * calls within a single dispatch (e.g. prompt + each context entry).
5929
+ * Pass the same Map instance to every rewrite() / rewriteAll() call.
5670
5930
  */
5671
5931
  async rewrite(text, opts) {
5672
5932
  const uris = parseUris(text);
5673
- if (uris.length === 0) return { text, resolvedHashes: [] };
5674
5933
  const replacements = /* @__PURE__ */ new Map();
5675
5934
  const resolvedHashes = [];
5676
5935
  for (const u of uris) {
@@ -5693,6 +5952,46 @@ var UriResolver = class {
5693
5952
  console.warn(`[uri-resolver] failed to resolve ${u.raw}: ${err.message}`);
5694
5953
  }
5695
5954
  }
5955
+ if (opts?.fetchUrls !== false) {
5956
+ const urlCache = opts?.urlCache;
5957
+ const urls = extractHttpUrls(text);
5958
+ for (const original of urls) {
5959
+ try {
5960
+ let resolution = urlCache?.get(original);
5961
+ if (!resolution) {
5962
+ const { cached, finalUrl, durationMs } = await this.assetCache.getOrFetchUrl(original, {
5963
+ signal: opts?.signal
5964
+ });
5965
+ resolution = { hash: cached.contentHash, localPath: cached.localPath, sizeBytes: cached.sizeBytes, mime: cached.mime, finalUrl, durationMs };
5966
+ urlCache?.set(original, resolution);
5967
+ opts?.urlObservations?.push({
5968
+ contentHash: cached.contentHash,
5969
+ mime: cached.mime,
5970
+ sizeBytes: cached.sizeBytes,
5971
+ strategy: "fetched-https",
5972
+ originalUrl: original,
5973
+ finalUrl,
5974
+ durationMs
5975
+ });
5976
+ }
5977
+ replacements.set(original, `file://${resolution.localPath}`);
5978
+ resolvedHashes.push(resolution.hash);
5979
+ if (opts?.pin) this.assetCache.pin(resolution.hash);
5980
+ } catch (err) {
5981
+ const message = err.message;
5982
+ console.warn(`[uri-resolver] failed to fetch ${original}: ${message}`);
5983
+ opts?.urlObservations?.push({
5984
+ contentHash: "",
5985
+ mime: null,
5986
+ sizeBytes: null,
5987
+ strategy: "error",
5988
+ originalUrl: original,
5989
+ error: message
5990
+ });
5991
+ }
5992
+ }
5993
+ }
5994
+ if (replacements.size === 0) return { text, resolvedHashes };
5696
5995
  let rewritten = text;
5697
5996
  for (const [raw, sub] of replacements) {
5698
5997
  rewritten = rewritten.split(raw).join(sub);
@@ -5730,6 +6029,27 @@ var UriResolver = class {
5730
6029
  function dedupe(xs) {
5731
6030
  return Array.from(new Set(xs));
5732
6031
  }
6032
+ function extractHttpUrls(text) {
6033
+ if (!text) return [];
6034
+ const seen = /* @__PURE__ */ new Set();
6035
+ const out = [];
6036
+ for (const m of text.matchAll(HTTP_URL_REGEX)) {
6037
+ let url = m[0];
6038
+ const punct = URL_TRAILING_PUNCT_RE.exec(url);
6039
+ if (punct) url = url.slice(0, -punct[0].length);
6040
+ if (!url) continue;
6041
+ try {
6042
+ const parsed = new URL(url);
6043
+ if (parsed.protocol !== "http:" && parsed.protocol !== "https:") continue;
6044
+ } catch {
6045
+ continue;
6046
+ }
6047
+ if (seen.has(url)) continue;
6048
+ seen.add(url);
6049
+ out.push(url);
6050
+ }
6051
+ return out;
6052
+ }
5733
6053
 
5734
6054
  // src/lib/logger.ts
5735
6055
  var LEVEL_ORDER = {
@@ -5801,7 +6121,7 @@ import * as path2 from "path";
5801
6121
 
5802
6122
  // src/daemon/skill-sync.ts
5803
6123
  import { promises as fsp2 } from "fs";
5804
- import { join as join11 } from "path";
6124
+ import { dirname as dirname8, join as join11, relative as relative2, sep } from "path";
5805
6125
  import { createHash as createHash3 } from "crypto";
5806
6126
  async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, signal) {
5807
6127
  if (!agentImUserId) return { synced: 0, skipped: 0, unchanged: 0 };
@@ -5818,29 +6138,141 @@ async function syncInstalledSkillsForDispatch(profile, agentImUserId, cloud, sig
5818
6138
  for (const entry of entries) {
5819
6139
  const slug = sanitizeSlug(trimmedStringFrom(entry.skill?.slug) ?? trimmedStringFrom(entry.slug));
5820
6140
  const skillId = trimmedStringFrom(entry.skill?.id);
5821
- const content = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
5822
- if (!slug || !content) {
6141
+ if (!slug) {
5823
6142
  skipped++;
5824
- if (slug && !content) {
5825
- process.stderr.write(`[daemon] skill sync skipped ${slug}: missing content
5826
- `);
5827
- await ackSkillSync(cloud, agentImUserId, { slug, error: "missing content" }, signal);
6143
+ continue;
6144
+ }
6145
+ let files = null;
6146
+ const manifestRaw = entry.skill?.contentManifest;
6147
+ if (typeof manifestRaw === "string" && manifestRaw.trim()) {
6148
+ files = parseManifest(manifestRaw, slug);
6149
+ }
6150
+ if (!files) {
6151
+ const legacyContent = contentStringFrom(entry.skill?.content) ?? contentStringFrom(entry.content);
6152
+ if (legacyContent) {
6153
+ const buf = Buffer.from(legacyContent, "utf8");
6154
+ files = [
6155
+ {
6156
+ path: "SKILL.md",
6157
+ size: buf.byteLength,
6158
+ sha256: sha256Buffer(buf),
6159
+ inline: true,
6160
+ content: buf.toString("base64")
6161
+ }
6162
+ ];
5828
6163
  }
6164
+ }
6165
+ if (!files || files.length === 0) {
6166
+ skipped++;
6167
+ process.stderr.write(`[daemon] skill sync skipped ${slug}: no manifest or content
6168
+ `);
6169
+ await ackSkillSync(cloud, agentImUserId, { skillId, slug, error: "missing content" }, signal);
5829
6170
  continue;
5830
6171
  }
5831
- const revision = sha2562(content);
5832
6172
  const skillDir = join11(skillsRoot, slug);
5833
6173
  await fsp2.mkdir(skillDir, { recursive: true });
5834
- const target = join11(skillDir, "SKILL.md");
5835
- const existing = await fsp2.readFile(target, "utf8").catch(() => null);
5836
- if (existing != null && sha2562(existing) === revision) {
5837
- unchanged++;
5838
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
6174
+ const localFiles = await walkLocalDir(skillDir);
6175
+ let dirty = false;
6176
+ let perFileFailures = 0;
6177
+ for (const file of files) {
6178
+ if (!isSafeRelativePath(file.path)) {
6179
+ process.stderr.write(`[daemon] skill sync ${slug}: skipping suspicious path "${file.path}"
6180
+ `);
6181
+ perFileFailures++;
6182
+ continue;
6183
+ }
6184
+ const targetPath = join11(skillDir, file.path);
6185
+ const normalizedTarget = targetPath + (targetPath.endsWith(sep) ? "" : "");
6186
+ if (normalizedTarget !== skillDir && !normalizedTarget.startsWith(skillDir + sep)) {
6187
+ process.stderr.write(`[daemon] skill sync ${slug}: path escapes skillDir "${file.path}"
6188
+ `);
6189
+ perFileFailures++;
6190
+ continue;
6191
+ }
6192
+ const existingHash = localFiles.get(file.path);
6193
+ if (existingHash === file.sha256) {
6194
+ localFiles.delete(file.path);
6195
+ continue;
6196
+ }
6197
+ let bytes = null;
6198
+ try {
6199
+ if (file.inline !== false && typeof file.content === "string") {
6200
+ bytes = Buffer.from(file.content, "base64");
6201
+ } else if (typeof file.url === "string" && file.url) {
6202
+ bytes = await downloadUrl(file.url, signal);
6203
+ } else {
6204
+ process.stderr.write(
6205
+ `[daemon] skill sync ${slug}: file ${file.path} has neither inline content nor url
6206
+ `
6207
+ );
6208
+ perFileFailures++;
6209
+ continue;
6210
+ }
6211
+ } catch (err) {
6212
+ process.stderr.write(
6213
+ `[daemon] skill sync ${slug}: failed to fetch ${file.path}: ${err.message}
6214
+ `
6215
+ );
6216
+ perFileFailures++;
6217
+ continue;
6218
+ }
6219
+ const downloadedHash = sha256Buffer(bytes);
6220
+ if (downloadedHash !== file.sha256) {
6221
+ process.stderr.write(
6222
+ `[daemon] skill sync ${slug}: hash mismatch for ${file.path} (expected ${file.sha256}, got ${downloadedHash})
6223
+ `
6224
+ );
6225
+ perFileFailures++;
6226
+ continue;
6227
+ }
6228
+ await fsp2.mkdir(dirname8(targetPath), { recursive: true });
6229
+ await fsp2.writeFile(targetPath, bytes);
6230
+ localFiles.delete(file.path);
6231
+ dirty = true;
6232
+ }
6233
+ for (const orphan of localFiles.keys()) {
6234
+ try {
6235
+ await fsp2.unlink(join11(skillDir, orphan));
6236
+ dirty = true;
6237
+ } catch (err) {
6238
+ if (err.code !== "ENOENT") {
6239
+ process.stderr.write(
6240
+ `[daemon] skill sync ${slug}: failed to remove orphan ${orphan}: ${err.message}
6241
+ `
6242
+ );
6243
+ }
6244
+ }
6245
+ }
6246
+ if (perFileFailures > 0) {
6247
+ skipped++;
6248
+ await ackSkillSync(
6249
+ cloud,
6250
+ agentImUserId,
6251
+ { skillId, slug, error: `${perFileFailures} file(s) failed` },
6252
+ signal
6253
+ );
5839
6254
  continue;
5840
6255
  }
5841
- await fsp2.writeFile(target, content, "utf8");
5842
- synced++;
5843
- await ackSkillSync(cloud, agentImUserId, { skillId, slug, revision }, signal);
6256
+ const localMerkle = computeMerkle(files);
6257
+ const declaredRevision = trimmedStringFrom(entry.skill?.contentManifestRevision);
6258
+ const revision = declaredRevision ?? localMerkle;
6259
+ if (declaredRevision && declaredRevision !== localMerkle) {
6260
+ process.stderr.write(
6261
+ `[daemon] skill sync ${slug}: declared revision ${declaredRevision} != computed ${localMerkle}; using computed
6262
+ `
6263
+ );
6264
+ }
6265
+ if (dirty) {
6266
+ synced++;
6267
+ } else {
6268
+ unchanged++;
6269
+ }
6270
+ await ackSkillSync(
6271
+ cloud,
6272
+ agentImUserId,
6273
+ { skillId, slug, revision: declaredRevision === localMerkle ? declaredRevision : localMerkle },
6274
+ signal
6275
+ );
5844
6276
  }
5845
6277
  return { synced, skipped, unchanged };
5846
6278
  }
@@ -5858,9 +6290,96 @@ function resolveSkillsRoot(profile) {
5858
6290
  }
5859
6291
  return null;
5860
6292
  }
5861
- function sha2562(value) {
6293
+ function sha256Buffer(value) {
5862
6294
  return createHash3("sha256").update(value).digest("hex");
5863
6295
  }
6296
+ function computeMerkle(files) {
6297
+ const sorted = [...files].sort((a, b) => a.path.localeCompare(b.path));
6298
+ const lines = sorted.map((f) => `${f.path}:${f.sha256}`).join("\n");
6299
+ return createHash3("sha256").update(lines).digest("hex");
6300
+ }
6301
+ function parseManifest(raw, slug) {
6302
+ try {
6303
+ const parsed = JSON.parse(raw);
6304
+ const arr = Array.isArray(parsed) ? parsed : parsed && typeof parsed === "object" && Array.isArray(parsed.files) ? parsed.files : null;
6305
+ if (!Array.isArray(arr)) {
6306
+ process.stderr.write(`[daemon] skill sync ${slug}: contentManifest is not an array
6307
+ `);
6308
+ return null;
6309
+ }
6310
+ const files = [];
6311
+ for (const item of arr) {
6312
+ if (!item || typeof item !== "object") continue;
6313
+ const rec = item;
6314
+ const path9 = typeof rec.path === "string" ? rec.path : null;
6315
+ const sha2563 = typeof rec.sha256 === "string" ? rec.sha256 : null;
6316
+ const size = typeof rec.size === "number" && Number.isFinite(rec.size) && rec.size >= 0 ? Math.floor(rec.size) : null;
6317
+ if (!path9 || !sha2563 || size === null) continue;
6318
+ const content = typeof rec.content === "string" ? rec.content : void 0;
6319
+ const url = typeof rec.url === "string" ? rec.url : void 0;
6320
+ const inline = typeof rec.inline === "boolean" ? rec.inline : content !== void 0 ? true : void 0;
6321
+ files.push({ path: path9, size, sha256: sha2563, inline, content, url });
6322
+ }
6323
+ return files;
6324
+ } catch (err) {
6325
+ process.stderr.write(
6326
+ `[daemon] skill sync ${slug}: invalid contentManifest JSON: ${err.message}
6327
+ `
6328
+ );
6329
+ return null;
6330
+ }
6331
+ }
6332
+ function isSafeRelativePath(p) {
6333
+ if (typeof p !== "string" || !p) return false;
6334
+ if (p.length > 512) return false;
6335
+ if (p.includes("\0")) return false;
6336
+ if (p.startsWith("/") || p.startsWith("\\")) return false;
6337
+ if (/^[A-Za-z]:[\\/]/.test(p)) return false;
6338
+ const segs = p.replace(/\\/g, "/").split("/");
6339
+ for (const seg of segs) {
6340
+ if (seg === "" || seg === "." || seg === "..") return false;
6341
+ }
6342
+ return true;
6343
+ }
6344
+ async function walkLocalDir(root) {
6345
+ const out = /* @__PURE__ */ new Map();
6346
+ async function walk2(dir) {
6347
+ let entries;
6348
+ try {
6349
+ entries = await fsp2.readdir(dir, { withFileTypes: true });
6350
+ } catch (err) {
6351
+ if (err.code === "ENOENT") return;
6352
+ throw err;
6353
+ }
6354
+ for (const ent of entries) {
6355
+ const full = join11(dir, ent.name);
6356
+ if (ent.isDirectory()) {
6357
+ await walk2(full);
6358
+ } else if (ent.isFile()) {
6359
+ try {
6360
+ const buf = await fsp2.readFile(full);
6361
+ const rel = relative2(root, full).split(sep).join("/");
6362
+ out.set(rel, sha256Buffer(buf));
6363
+ } catch {
6364
+ }
6365
+ }
6366
+ }
6367
+ }
6368
+ await walk2(root);
6369
+ return out;
6370
+ }
6371
+ async function downloadUrl(url, signal) {
6372
+ const lower = url.toLowerCase();
6373
+ if (!lower.startsWith("http://") && !lower.startsWith("https://")) {
6374
+ throw new Error(`unsupported url scheme: ${url.slice(0, 32)}`);
6375
+ }
6376
+ const res = await fetch(url, { signal });
6377
+ if (!res.ok) {
6378
+ throw new Error(`fetch ${url} failed: HTTP ${res.status}`);
6379
+ }
6380
+ const ab = await res.arrayBuffer();
6381
+ return Buffer.from(ab);
6382
+ }
5864
6383
  function normalizeInstalledSkills(data) {
5865
6384
  if (Array.isArray(data)) return data.filter(isInstalledSkillEntry);
5866
6385
  if (!data || typeof data !== "object" || Array.isArray(data)) return [];
@@ -5980,12 +6499,22 @@ async function handleDispatch(payload, requestId, deps) {
5980
6499
  payload.prompt = hashRefResult.text;
5981
6500
  }
5982
6501
  }
5983
- const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, { pin: true });
6502
+ const urlCache = /* @__PURE__ */ new Map();
6503
+ const urlObservations = [];
6504
+ const rewrittenPrompt = await deps.uriResolver.rewrite(payload.prompt, {
6505
+ pin: true,
6506
+ urlCache,
6507
+ urlObservations
6508
+ });
5984
6509
  resolvedHashes.push(...rewrittenPrompt.resolvedHashes);
5985
6510
  let rewrittenContext = [];
5986
6511
  if (payload.context && payload.context.length > 0) {
5987
6512
  const contents = payload.context.map((e) => e.content);
5988
- const r = await deps.uriResolver.rewriteAll(contents, { pin: true });
6513
+ const r = await deps.uriResolver.rewriteAll(contents, {
6514
+ pin: true,
6515
+ urlCache,
6516
+ urlObservations
6517
+ });
5989
6518
  resolvedHashes.push(...r.resolvedHashes);
5990
6519
  rewrittenContext = payload.context.map((e, i) => ({ ...e, content: r.texts[i] }));
5991
6520
  }
@@ -6105,7 +6634,7 @@ async function handleDispatch(payload, requestId, deps) {
6105
6634
  } : result.error,
6106
6635
  ...collectedAssetIds.length > 0 ? { assetIds: collectedAssetIds } : {},
6107
6636
  metrics: result.metrics,
6108
- ...assetResolution.observability.length > 0 ? { assetObservability: assetResolution.observability } : {}
6637
+ ...assetResolution.observability.length > 0 || urlObservations.length > 0 ? { assetObservability: [...assetResolution.observability, ...urlObservations] } : {}
6109
6638
  };
6110
6639
  await writeBridgeMetadata(payload.taskId, deps.cloud, result.metadata, deps.signal);
6111
6640
  await writeObservabilityMetadata(
@@ -7272,10 +7801,10 @@ async function walkAndDigest(root, current) {
7272
7801
  }
7273
7802
  if (!st.isFile()) continue;
7274
7803
  const buf = await fs2.readFile(full);
7275
- const sha2564 = createHash4("sha256").update(buf).digest("hex");
7804
+ const sha2563 = createHash4("sha256").update(buf).digest("hex");
7276
7805
  out.push({
7277
7806
  path: rel,
7278
- sha256: sha2564,
7807
+ sha256: sha2563,
7279
7808
  sizeBytes: st.size,
7280
7809
  mtime: Math.floor(st.mtimeMs)
7281
7810
  });
@@ -7482,7 +8011,7 @@ var MemoryStore = class {
7482
8011
  }
7483
8012
  const db = this.requireDb();
7484
8013
  const now = Date.now();
7485
- const contentHash = sha2563(input.content);
8014
+ const contentHash = sha2562(input.content);
7486
8015
  const payload = sealPlaintext(input.content);
7487
8016
  if (payload.kind !== "inline") {
7488
8017
  throw new Error("MemoryStore.write: non-inline payload not yet supported in phase-0");
@@ -7676,7 +8205,7 @@ var MemoryStore = class {
7676
8205
  };
7677
8206
  }
7678
8207
  };
7679
- function sha2563(s) {
8208
+ function sha2562(s) {
7680
8209
  return createHash5("sha256").update(s, "utf8").digest("hex");
7681
8210
  }
7682
8211
 
@@ -12752,7 +13281,7 @@ import { Command as Command13 } from "commander";
12752
13281
  import { execFileSync as execFileSync2 } from "child_process";
12753
13282
  import { existsSync as existsSync18, mkdirSync as mkdirSync10, rmSync, renameSync as renameSync2 } from "fs";
12754
13283
  import { homedir as homedir9 } from "os";
12755
- import { dirname as dirname10, join as join21 } from "path";
13284
+ import { dirname as dirname11, join as join21 } from "path";
12756
13285
  import { setTimeout as sleep4 } from "timers/promises";
12757
13286
  init_util();
12758
13287
  init_ui();
@@ -12806,8 +13335,8 @@ function buildResetCommand() {
12806
13335
  rmSync(paths.root, { recursive: true, force: true });
12807
13336
  } else {
12808
13337
  if (!archivePath) throw new Error("internal: archivePath missing");
12809
- if (!existsSync18(dirname10(archivePath))) {
12810
- mkdirSync10(dirname10(archivePath), { recursive: true });
13338
+ if (!existsSync18(dirname11(archivePath))) {
13339
+ mkdirSync10(dirname11(archivePath), { recursive: true });
12811
13340
  }
12812
13341
  renameSync2(paths.root, archivePath);
12813
13342
  }
@@ -12817,8 +13346,8 @@ function buildResetCommand() {
12817
13346
  rmSync(assetSyncRoot, { recursive: true, force: true });
12818
13347
  } else {
12819
13348
  if (!assetSyncArchivePath) throw new Error("internal: assetSyncArchivePath missing");
12820
- if (!existsSync18(dirname10(assetSyncArchivePath))) {
12821
- mkdirSync10(dirname10(assetSyncArchivePath), { recursive: true });
13349
+ if (!existsSync18(dirname11(assetSyncArchivePath))) {
13350
+ mkdirSync10(dirname11(assetSyncArchivePath), { recursive: true });
12822
13351
  }
12823
13352
  renameSync2(assetSyncRoot, assetSyncArchivePath);
12824
13353
  }
@@ -12828,8 +13357,8 @@ function buildResetCommand() {
12828
13357
  rmSync(hermesHome, { recursive: true, force: true });
12829
13358
  } else {
12830
13359
  if (!hermesArchivePath) throw new Error("internal: hermesArchivePath missing");
12831
- if (!existsSync18(dirname10(hermesArchivePath))) {
12832
- mkdirSync10(dirname10(hermesArchivePath), { recursive: true });
13360
+ if (!existsSync18(dirname11(hermesArchivePath))) {
13361
+ mkdirSync10(dirname11(hermesArchivePath), { recursive: true });
12833
13362
  }
12834
13363
  renameSync2(hermesHome, hermesArchivePath);
12835
13364
  }
@@ -14161,7 +14690,7 @@ async function readResponseError(res) {
14161
14690
 
14162
14691
  // src/cli/index.ts
14163
14692
  init_ui();
14164
- var VERSION = "2.0.0";
14693
+ var VERSION = "2.0.1";
14165
14694
  function buildProgram() {
14166
14695
  const program = new Command20("prismer").description("Prismer Cloud daemon CLI (TS-only).").version(VERSION);
14167
14696
  program.addCommand(buildBannerCommand());