norns-sdk 0.1.0 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/CHANGELOG.md CHANGED
@@ -2,6 +2,26 @@
2
2
 
3
3
  All notable changes to `norns-sdk` are documented in this file.
4
4
 
5
+ ## [0.3.0] - 2026-10-22
6
+
7
+ ### Added
8
+ - A worker holding a scoped key (`nrnw_...`, issued for one gard's worker)
9
+ skips the agent registration on `start()`, since the key cannot reach the
10
+ REST API; the definitions are registered with a tenant key instead.
11
+ `isScopedKey()` is exported.
12
+ - `KeyRevokedError`: `worker.stopped` rejects with it when Norns revokes the
13
+ key the worker joined on.
14
+
15
+ ## [0.2.0] - 2026-10-16
16
+
17
+ ### Added
18
+ - The worker joins with `features: ["message_cache"]` and keeps the message
19
+ list Norns last sent it per run, so Norns can send later steps as
20
+ `messages_delta` (the change) instead of the whole history. A delta on a
21
+ base the worker no longer holds is answered with `message_cache_miss` and
22
+ Norns resends in full. Older Norns ignores the feature and sends the full
23
+ list as before.
24
+
5
25
  ## [0.1.0] - 2026-10-15
6
26
 
7
27
  ### Added
package/README.md CHANGED
@@ -90,6 +90,12 @@ execute: async ({ port }, ctx) => {
90
90
 
91
91
  A fatally rejected claim (bad token, destroyed gard) rejects `start()` with `JoinError` instead of reconnect-looping. If the gard is destroyed while the worker is connected, `worker.stopped` rejects with `GardDestroyedError`.
92
92
 
93
+ A worker inside a gard should hold a [scoped key](https://github.com/nornscode/norns/blob/main/docs/gards.md#scoped-worker-keys) (`nrnw_...`) rather than the tenant key: it can join the worker socket, claim its gard and serve tasks, and nothing else. With one, `start()` skips the agent registration, since the key cannot reach the REST API; register the definitions with a tenant key instead. Pass `capabilities: ["tools"]` for a worker with no LLM access, matching what the key allows. If the key is revoked while the worker is connected, `worker.stopped` rejects with `KeyRevokedError`.
94
+
95
+ ```ts
96
+ const worker = new NornsWorker({ agents: [coder], gard: 3, claimToken: "tok_...", capabilities: ["tools"] });
97
+ ```
98
+
93
99
  ### Shutdown
94
100
 
95
101
  On SIGTERM or SIGINT the worker drains instead of dying mid-task: it tells Norns to stop sending it work, finishes the tasks it holds, reports their results, leaves the channel, and the process exits. Tasks still running after `shutdownTimeoutMs` (default 30 seconds, or `NORNS_SHUTDOWN_TIMEOUT` in seconds) are dropped and Norns re-dispatches them. A second signal exits at once.
package/dist/index.cjs CHANGED
@@ -23,6 +23,7 @@ __export(index_exports, {
23
23
  DEFAULT_MODEL: () => DEFAULT_MODEL,
24
24
  GardDestroyedError: () => GardDestroyedError,
25
25
  JoinError: () => JoinError,
26
+ KeyRevokedError: () => KeyRevokedError,
26
27
  NornsApiError: () => NornsApiError,
27
28
  NornsClient: () => NornsClient,
28
29
  NornsError: () => NornsError,
@@ -33,6 +34,7 @@ __export(index_exports, {
33
34
  consoleLogger: () => consoleLogger,
34
35
  defineAgent: () => defineAgent,
35
36
  inferProvider: () => inferProvider,
37
+ isScopedKey: () => isScopedKey,
36
38
  jsonSchema: () => import_ai4.jsonSchema,
37
39
  silentLogger: () => silentLogger,
38
40
  tool: () => tool
@@ -245,6 +247,12 @@ var GardDestroyedError = class extends NornsError {
245
247
  this.name = "GardDestroyedError";
246
248
  }
247
249
  };
250
+ var KeyRevokedError = class extends NornsError {
251
+ constructor() {
252
+ super("the worker's scoped key was revoked");
253
+ this.name = "KeyRevokedError";
254
+ }
255
+ };
248
256
  var StreamClosedError = class extends NornsError {
249
257
  reason;
250
258
  constructor(reason) {
@@ -573,6 +581,9 @@ function resolveApiKey(apiKey) {
573
581
  if (!value) throw new NornsError("Norns API key missing: pass `apiKey` or set NORNS_API_KEY");
574
582
  return value;
575
583
  }
584
+ function isScopedKey(apiKey) {
585
+ return apiKey.startsWith("nrnw_");
586
+ }
576
587
  function wsBase(url) {
577
588
  return url.replace(/^http:\/\//, "ws://").replace(/^https:\/\//, "wss://");
578
589
  }
@@ -1149,6 +1160,51 @@ function neutralUsage(usage) {
1149
1160
  return out;
1150
1161
  }
1151
1162
 
1163
+ // src/history-cache.ts
1164
+ var HistoryCache = class {
1165
+ #runs = /* @__PURE__ */ new Map();
1166
+ #maxRuns;
1167
+ constructor(maxRuns = 256) {
1168
+ this.#maxRuns = maxRuns;
1169
+ }
1170
+ /** The task with `messages` filled in, or `undefined` on a miss. */
1171
+ resolve(task) {
1172
+ if (task.purpose === "compact") return task;
1173
+ const runId = task.run_id;
1174
+ const delta = task.messages_delta;
1175
+ if (delta === void 0) {
1176
+ if (runId !== void 0 && Array.isArray(task.messages)) this.#remember(runId, task.messages);
1177
+ return task;
1178
+ }
1179
+ const previous = runId === void 0 ? void 0 : this.#runs.get(runId);
1180
+ if (runId === void 0 || previous === void 0 || previous.length !== delta.base_count) {
1181
+ return void 0;
1182
+ }
1183
+ const messages = [...previous.slice(delta.drop), ...delta.append];
1184
+ this.#remember(runId, messages);
1185
+ const resolved = { ...task, messages };
1186
+ delete resolved.messages_delta;
1187
+ return resolved;
1188
+ }
1189
+ /** Forget everything: a new connection gets the full history anyway. */
1190
+ clear() {
1191
+ this.#runs.clear();
1192
+ }
1193
+ /** How many runs are held; for tests. */
1194
+ get size() {
1195
+ return this.#runs.size;
1196
+ }
1197
+ #remember(runId, messages) {
1198
+ this.#runs.delete(runId);
1199
+ this.#runs.set(runId, messages);
1200
+ while (this.#runs.size > this.#maxRuns) {
1201
+ const oldest = this.#runs.keys().next().value;
1202
+ if (oldest === void 0) break;
1203
+ this.#runs.delete(oldest);
1204
+ }
1205
+ }
1206
+ };
1207
+
1152
1208
  // src/worker.ts
1153
1209
  var TOPIC = "worker:lobby";
1154
1210
  var NornsWorker = class {
@@ -1169,6 +1225,7 @@ var NornsWorker = class {
1169
1225
  #client;
1170
1226
  #resolveModel;
1171
1227
  #inFlight = /* @__PURE__ */ new Set();
1228
+ #history = new HistoryCache();
1172
1229
  #state = "idle";
1173
1230
  #socket;
1174
1231
  #channel;
@@ -1237,9 +1294,15 @@ var NornsWorker = class {
1237
1294
  return this.#starting;
1238
1295
  }
1239
1296
  async #start() {
1240
- for (const agent of this.#agents) {
1241
- const info = await this.#client.agents.ensure(agent);
1242
- this.#logger.info(`Registered agent '${agent.name}' (id=${info.id})`);
1297
+ if (isScopedKey(this.#apiKey)) {
1298
+ this.#logger.info(
1299
+ "Worker key is scoped (nrnw_): skipping agent registration. Register the agent definitions with a tenant key."
1300
+ );
1301
+ } else {
1302
+ for (const agent of this.#agents) {
1303
+ const info = await this.#client.agents.ensure(agent);
1304
+ this.#logger.info(`Registered agent '${agent.name}' (id=${info.id})`);
1305
+ }
1243
1306
  }
1244
1307
  if (this.#capabilities.includes("llm")) {
1245
1308
  for (const model of new Set(this.#agents.map((a) => a.model))) {
@@ -1261,7 +1324,10 @@ var NornsWorker = class {
1261
1324
  ...this.#socketOptions
1262
1325
  });
1263
1326
  this.#socket = socket;
1264
- socket.onOpen(() => this.#logger.info(`Connected to ${this.url}`));
1327
+ socket.onOpen(() => {
1328
+ this.#logger.info(`Connected to ${this.url}`);
1329
+ this.#history.clear();
1330
+ });
1265
1331
  socket.onError((err) => {
1266
1332
  if (this.#state === "starting" || this.#state === "running") {
1267
1333
  this.#logger.warn(`Connection error (${errorMessage(err)}); retrying`);
@@ -1287,6 +1353,10 @@ var NornsWorker = class {
1287
1353
  this.#logger.error("This worker's gard was destroyed; shutting down");
1288
1354
  this.#fail(new GardDestroyedError(this.gard));
1289
1355
  });
1356
+ channel.on("key_revoked", () => {
1357
+ this.#logger.error("This worker's key was revoked; shutting down");
1358
+ this.#fail(new KeyRevokedError());
1359
+ });
1290
1360
  channel.onError((reason) => {
1291
1361
  if (this.#state === "running")
1292
1362
  this.#logger.warn(`Channel error (${errorMessage(reason)}); rejoining`);
@@ -1320,7 +1390,10 @@ var NornsWorker = class {
1320
1390
  const params = {
1321
1391
  worker_id: this.workerId,
1322
1392
  tools: [...this.#tools.values()].map(toolRegistration),
1323
- capabilities: this.#capabilities
1393
+ capabilities: this.#capabilities,
1394
+ // This worker keeps the history it was last sent per run, so Norns may
1395
+ // send later steps as the change only (HistoryCache).
1396
+ features: ["message_cache"]
1324
1397
  };
1325
1398
  if (this.gard !== void 0) {
1326
1399
  params.gard = this.gard;
@@ -1449,7 +1522,14 @@ var NornsWorker = class {
1449
1522
  (resp) => this.#logger.warn(`Server rejected result for ${taskId}: ${errorMessage(resp)}`)
1450
1523
  );
1451
1524
  }
1452
- async #runLlm(task, signal) {
1525
+ async #runLlm(received, signal) {
1526
+ const task = this.#history.resolve(received);
1527
+ if (task === void 0) {
1528
+ this.#logger.info(
1529
+ "LLM task arrived as a delta on an unknown base; asking for the full history"
1530
+ );
1531
+ return { status: "error", error: "message_cache_miss" };
1532
+ }
1453
1533
  const model = task.model ?? "?";
1454
1534
  const tools = task.tools ?? [];
1455
1535
  this.#logger.info(
@@ -1590,6 +1670,7 @@ var NornsWorker = class {
1590
1670
  DEFAULT_MODEL,
1591
1671
  GardDestroyedError,
1592
1672
  JoinError,
1673
+ KeyRevokedError,
1593
1674
  NornsApiError,
1594
1675
  NornsClient,
1595
1676
  NornsError,
@@ -1600,6 +1681,7 @@ var NornsWorker = class {
1600
1681
  consoleLogger,
1601
1682
  defineAgent,
1602
1683
  inferProvider,
1684
+ isScopedKey,
1603
1685
  jsonSchema,
1604
1686
  silentLogger,
1605
1687
  tool