lightnode-sdk 0.4.0 → 0.4.2

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/add.d.ts CHANGED
@@ -36,6 +36,12 @@ export declare function addAnalyticsDashboard(opts?: AddOpts): {
36
36
  template: Template;
37
37
  network: Network;
38
38
  };
39
+ export declare function addAgent(opts?: AddOpts): {
40
+ written: WrittenFile[];
41
+ install: string;
42
+ template: Template;
43
+ network: Network;
44
+ };
39
45
  export declare function addChat(opts?: AddOpts): {
40
46
  written: WrittenFile[];
41
47
  install: string;
package/dist/add.js CHANGED
@@ -897,6 +897,175 @@ while (true) {
897
897
  }
898
898
  }
899
899
  `;
900
+ // ---------------------------------------------------------------------------
901
+ // `lightnode add agent` - drop in a scheduled/loop inference scaffold. Good
902
+ // for daily summarizers, monitoring agents, cron jobs that run inference on a
903
+ // fixed cadence. For Next.js: a /api/agent route that Vercel Cron (or any
904
+ // cron-style trigger) can hit on schedule. For Node: a standalone script that
905
+ // runs inference on an interval until you kill it.
906
+ // ---------------------------------------------------------------------------
907
+ const NEXTJS_AGENT_ROUTE = `// app/api/agent/route.ts
908
+ // Generated by 'lightnode add agent'.
909
+ //
910
+ // Set up Vercel Cron in vercel.json:
911
+ // { "crons": [{ "path": "/api/agent", "schedule": "0 9 * * *" }] }
912
+ // That hits this route every day at 09:00 UTC.
913
+ //
914
+ // Auth your cron call: Vercel Cron sends a Bearer token in the Authorization
915
+ // header that you can verify here against CRON_SECRET. See:
916
+ // https://vercel.com/docs/cron-jobs/manage-cron-jobs#securing-cron-jobs
917
+ import { NextResponse } from "next/server";
918
+ import WS from "ws";
919
+ import { createPublicClient, createWalletClient, http } from "viem";
920
+ import { privateKeyToAccount } from "viem/accounts";
921
+ import { LightNode, GatewayClient, runInference, consumerGatewayUrl, type NetworkId } from "lightnode-sdk";
922
+
923
+ export const runtime = "nodejs";
924
+ export const dynamic = "force-dynamic";
925
+ export const maxDuration = 120;
926
+
927
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
928
+ const MODEL = process.env.MODEL ?? "llama3-8b";
929
+ const TASK_PROMPT = process.env.AGENT_TASK ?? "Summarize today's news in 3 bullet points.";
930
+
931
+ export async function GET(req: Request) {
932
+ // Verify Vercel Cron sent this. Set CRON_SECRET in your Vercel env vars; the
933
+ // platform automatically injects it as the Bearer token on cron-fired requests.
934
+ const auth = req.headers.get("authorization");
935
+ if (process.env.CRON_SECRET && auth !== \`Bearer \${process.env.CRON_SECRET}\`) {
936
+ return NextResponse.json({ error: "unauthorized" }, { status: 401 });
937
+ }
938
+ if (!process.env.PRIVATE_KEY?.startsWith("0x")) {
939
+ return NextResponse.json({ error: "PRIVATE_KEY not configured" }, { status: 500 });
940
+ }
941
+
942
+ const ln = new LightNode(NETWORK);
943
+ const acct = privateKeyToAccount(process.env.PRIVATE_KEY as \`0x\${string}\`);
944
+ const chain = { id: ln.network.chainId, name: ln.network.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [ln.network.rpc] } } };
945
+ const publicClient = createPublicClient({ transport: http(ln.network.rpc), chain });
946
+ const wallet = createWalletClient({ account: acct, transport: http(ln.network.rpc), chain });
947
+
948
+ // SIWE -> JWT (one handshake per agent run).
949
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
950
+ if (!ch.message) return NextResponse.json({ error: "auth challenge failed" }, { status: 502 });
951
+ const sig = await wallet.signMessage({ message: ch.message });
952
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
953
+ if (!verify.token) return NextResponse.json({ error: "auth verify failed" }, { status: 502 });
954
+ const gateway = new GatewayClient({ network: NETWORK, bearer: verify.token });
955
+
956
+ // ---- your agent's logic ------------------------------------------------
957
+ // Replace this block with whatever your scheduled task should do. By default
958
+ // it just runs a single inference call with the AGENT_TASK prompt and stores
959
+ // the result; you might fetch upstream data first, run multiple turns, post
960
+ // results to Slack/Discord/a DB, etc.
961
+ try {
962
+ const result = await runInference({
963
+ prompt: TASK_PROMPT,
964
+ gateway, wallet, publicClient, network: ln.network,
965
+ model: MODEL, WebSocket: WS, maxRetries: 2,
966
+ });
967
+ // TODO: persist result.answer somewhere durable (DB, S3, send to Slack, etc.).
968
+ return NextResponse.json({
969
+ ok: true,
970
+ answer: result.answer,
971
+ txs: result.txs,
972
+ jobId: result.jobId.toString(),
973
+ ranAt: new Date().toISOString(),
974
+ });
975
+ } catch (e) {
976
+ return NextResponse.json({ ok: false, error: (e as Error).message }, { status: 500 });
977
+ }
978
+ }
979
+ `;
980
+ const NEXTJS_AGENT_VERCEL_JSON = `{
981
+ "crons": [
982
+ {
983
+ "path": "/api/agent",
984
+ "schedule": "0 9 * * *"
985
+ }
986
+ ]
987
+ }
988
+ `;
989
+ const NODE_AGENT_SCRIPT = `// agent.ts
990
+ // Generated by 'lightnode add agent'. A long-running script that runs inference
991
+ // on a fixed cadence. Use it under pm2, systemd, a Docker container - anywhere
992
+ // you'd run a daemon.
993
+ // npm install lightnode-sdk viem ws
994
+ // AGENT_INTERVAL_MS=3600000 tsx agent.ts
995
+ import WS from "ws";
996
+ import { createPublicClient, createWalletClient, http } from "viem";
997
+ import { privateKeyToAccount } from "viem/accounts";
998
+ import { LightNode, GatewayClient, runInference, consumerGatewayUrl, isStalledWorker, type NetworkId } from "lightnode-sdk";
999
+
1000
+ const NETWORK = (process.env.NETWORK ?? "testnet") as NetworkId;
1001
+ const MODEL = process.env.MODEL ?? "llama3-8b";
1002
+ const TASK_PROMPT = process.env.AGENT_TASK ?? "Summarize today's news in 3 bullet points.";
1003
+ const INTERVAL_MS = Number(process.env.AGENT_INTERVAL_MS ?? 24 * 60 * 60 * 1000); // default daily
1004
+ const PRIVATE_KEY = process.env.PRIVATE_KEY as \`0x\${string}\` | undefined;
1005
+ if (!PRIVATE_KEY?.startsWith("0x") || PRIVATE_KEY.length !== 66) { console.error("set PRIVATE_KEY in .env"); process.exit(1); }
1006
+
1007
+ const ln = new LightNode(NETWORK);
1008
+ const acct = privateKeyToAccount(PRIVATE_KEY);
1009
+ const chain = { id: ln.network.chainId, name: ln.network.label, nativeCurrency: { name: "LCAI", symbol: "LCAI", decimals: 18 }, rpcUrls: { default: { http: [ln.network.rpc] } } };
1010
+ const publicClient = createPublicClient({ transport: http(ln.network.rpc), chain });
1011
+ const wallet = createWalletClient({ account: acct, transport: http(ln.network.rpc), chain });
1012
+
1013
+ // One SIWE handshake per process; refreshed lazily when the JWT expires.
1014
+ async function freshGateway() {
1015
+ const ch = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/challenge?address=\${acct.address}\`)).json() as { message?: string };
1016
+ if (!ch.message) throw new Error("auth challenge failed");
1017
+ const sig = await wallet.signMessage({ message: ch.message });
1018
+ const verify = await (await fetch(\`\${consumerGatewayUrl(NETWORK)}/api/auth/verify\`, { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify({ message: ch.message, signature: sig }) })).json() as { token?: string };
1019
+ if (!verify.token) throw new Error("auth verify failed");
1020
+ return new GatewayClient({ network: NETWORK, bearer: verify.token });
1021
+ }
1022
+
1023
+ let gateway = await freshGateway();
1024
+ console.log(\`▶ agent ready on \${NETWORK}, interval=\${(INTERVAL_MS / 1000 / 60).toFixed(1)} min, task=\${TASK_PROMPT.slice(0, 60)}…\\n\`);
1025
+
1026
+ async function tick() {
1027
+ console.log(\`[\${new Date().toISOString()}] tick\`);
1028
+ try {
1029
+ const result = await runInference({
1030
+ prompt: TASK_PROMPT,
1031
+ gateway, wallet, publicClient, network: ln.network,
1032
+ model: MODEL, WebSocket: WS, maxRetries: 2,
1033
+ });
1034
+ // ---- your agent's output sink: write to a DB, post to Slack, etc. ----
1035
+ console.log(\`[\${new Date().toISOString()}] answer:\\n\${result.answer}\\n (createSession=\${result.txs.createSession.slice(0, 14)}…, jobCompleted=\${result.txs.jobCompleted.slice(0, 14)}…)\\n\`);
1036
+ } catch (e) {
1037
+ if (isStalledWorker(e)) console.error(\`[\${new Date().toISOString()}] all workers stalled this round; protocol refunds, skipping\`);
1038
+ else if ((e as Error).message.includes("auth")) { console.warn("JWT expired; re-authing"); gateway = await freshGateway(); }
1039
+ else console.error(\`[\${new Date().toISOString()}] tick failed:\`, (e as Error).message);
1040
+ }
1041
+ }
1042
+
1043
+ await tick(); // run once immediately
1044
+ setInterval(tick, INTERVAL_MS);
1045
+
1046
+ // Keep alive forever.
1047
+ process.on("SIGINT", () => { console.log("\\n▶ agent stopped"); process.exit(0); });
1048
+ `;
1049
+ export function addAgent(opts = {}) {
1050
+ const cwd = opts.cwd ?? process.cwd();
1051
+ const network = opts.network ?? "testnet";
1052
+ const template = opts.template && opts.template !== "auto" ? opts.template : detectTemplate(cwd);
1053
+ const force = !!opts.force;
1054
+ const written = [];
1055
+ if (template === "nextjs-api") {
1056
+ written.push(writeFile(path.join(cwd, "app/api/agent/route.ts"), NEXTJS_AGENT_ROUTE, force));
1057
+ // Only add vercel.json if there isn't one; merging is too fragile to do
1058
+ // blindly here (we'd risk clobbering the user's existing config).
1059
+ if (!fs.existsSync(path.join(cwd, "vercel.json"))) {
1060
+ written.push(writeFile(path.join(cwd, "vercel.json"), NEXTJS_AGENT_VERCEL_JSON, force));
1061
+ }
1062
+ }
1063
+ else {
1064
+ written.push(writeFile(path.join(cwd, "agent.ts"), NODE_AGENT_SCRIPT, force));
1065
+ }
1066
+ written.push(writeFile(path.join(cwd, ".env.example"), ENV_EXAMPLE(network), force));
1067
+ return { written, install: `npm install ${depsNeeded(template).join(" ")}`, template, network };
1068
+ }
900
1069
  export function addChat(opts = {}) {
901
1070
  const cwd = opts.cwd ?? process.cwd();
902
1071
  const network = opts.network ?? "testnet";
package/dist/cli.js CHANGED
@@ -1,6 +1,6 @@
1
1
  #!/usr/bin/env node
2
2
  import { LightNode, modelStatsCsv, workerStatsCsv, workerJobsCsv } from "./index.js";
3
- import { addInference, addAnalyticsDashboard, addNftMint, addChat } from "./add.js";
3
+ import { addInference, addAnalyticsDashboard, addNftMint, addChat, addAgent } from "./add.js";
4
4
  function flag(name) {
5
5
  const i = process.argv.indexOf(name);
6
6
  return i >= 0 ? process.argv[i + 1] : undefined;
@@ -28,6 +28,7 @@ const HELP = `lightnode <command> [--net mainnet|testnet]
28
28
 
29
29
  add inference end-to-end encrypted inference route/script
30
30
  add chat chat-style UI with conversation history
31
+ add agent scheduled/loop inference (cron-style)
31
32
  add analytics-dashboard read-only network + worker analytics page
32
33
  add nft-mint-with-inference AI-generated NFT metadata (provenance on-chain)
33
34
  (all add commands: [--template auto|nextjs-api|hono|node] [--force])
@@ -103,7 +104,7 @@ async function main() {
103
104
  const template = flag("--template") ?? "auto";
104
105
  const force = process.argv.includes("--force");
105
106
  const network = (net === "mainnet" ? "mainnet" : "testnet");
106
- const known = ["inference", "chat", "analytics-dashboard", "nft-mint-with-inference"];
107
+ const known = ["inference", "chat", "agent", "analytics-dashboard", "nft-mint-with-inference"];
107
108
  if (!known.includes(sub ?? "")) {
108
109
  die(`usage: lightnode add <${known.join("|")}> [--template auto|nextjs-api|hono|node] [--net testnet|mainnet] [--force]`);
109
110
  }
@@ -113,7 +114,9 @@ async function main() {
113
114
  ? addNftMint({ template, network, force })
114
115
  : sub === "chat"
115
116
  ? addChat({ template, network, force })
116
- : addInference({ template, network, force });
117
+ : sub === "agent"
118
+ ? addAgent({ template, network, force })
119
+ : addInference({ template, network, force });
117
120
  console.log(`▶ add ${sub} (${result.template} template, default network ${result.network})`);
118
121
  for (const f of result.written) {
119
122
  if (f.skipped)
@@ -128,9 +131,16 @@ async function main() {
128
131
  else {
129
132
  console.log(`\nNext steps:`);
130
133
  console.log(` 1. ${result.install}`);
131
- if (sub === "nft-mint-with-inference" || sub === "inference" || sub === "chat") {
134
+ if (sub === "nft-mint-with-inference" || sub === "inference" || sub === "chat" || sub === "agent") {
132
135
  console.log(` 2. cp .env.example .env (and put a funded ${result.network} PRIVATE_KEY in it)`);
133
- if (sub === "chat" && result.template === "nextjs-api") {
136
+ if (sub === "agent" && result.template === "nextjs-api") {
137
+ console.log(` 3. Set CRON_SECRET in your Vercel env vars + edit AGENT_TASK in .env`);
138
+ console.log(` 4. Deploy. Vercel Cron fires /api/agent on the schedule in vercel.json`);
139
+ }
140
+ else if (sub === "agent") {
141
+ console.log(` 3. AGENT_INTERVAL_MS=3600000 tsx agent.ts # or run under pm2/systemd`);
142
+ }
143
+ else if (sub === "chat" && result.template === "nextjs-api") {
134
144
  console.log(` 3. Make sure /api/inference is mounted too (run: npx lightnode add inference)`);
135
145
  console.log(` 4. npm run dev, open /chat`);
136
146
  }
@@ -159,7 +159,7 @@ export interface RunInferenceArgs {
159
159
  onChunk?: (chunk: string, totalSoFar: string) => void;
160
160
  /** Retry count if a worker stalls. Default 2 (so up to 3 paid attempts). */
161
161
  maxRetries?: number;
162
- /** How long to wait for JobCompleted before declaring the worker stalled. Default 90s. */
162
+ /** How long to wait for JobCompleted before declaring the worker stalled. Default 120s. */
163
163
  jobCompletedTimeoutMs?: number;
164
164
  /**
165
165
  * WebSocket constructor. In a browser, omit and `globalThis.WebSocket` is
@@ -179,7 +179,12 @@ export interface RunInferenceResult {
179
179
  txs: {
180
180
  createSession: `0x${string}`;
181
181
  submitJob: `0x${string}`;
182
- jobCompleted: `0x${string}`;
182
+ /**
183
+ * Worker's commit-result tx. Null if the on-chain event hasn't landed by the
184
+ * deadline but the WS-delivered, session-key-decrypted answer DID arrive -
185
+ * in that case the answer is still authentic; this is just the explorer link.
186
+ */
187
+ jobCompleted: `0x${string}` | null;
183
188
  };
184
189
  /** The dispatcher-assigned worker that produced this response. */
185
190
  worker: `0x${string}`;
package/dist/inference.js CHANGED
@@ -190,7 +190,7 @@ function topicAsUint(hex) {
190
190
  return BigInt(hex);
191
191
  }
192
192
  async function runOneAttempt(args, attempt) {
193
- const { prompt, gateway, wallet, publicClient, network, model = "llama3-8b", onChunk, jobCompletedTimeoutMs = 90000, } = args;
193
+ const { prompt, gateway, wallet, publicClient, network, model = "llama3-8b", onChunk, jobCompletedTimeoutMs = 120000, } = args;
194
194
  const WS = pickWebSocket(args.WebSocket);
195
195
  const relayUrl = args.relayUrl ?? `wss://relay.${network.id}.lightchain.ai/ws`;
196
196
  // 1. prepareSession
@@ -326,6 +326,11 @@ async function runOneAttempt(args, attempt) {
326
326
  throw new Error("JobSubmitted log missing in submitJob receipt");
327
327
  const jobId = topicAsUint(jobLog.topics[1]);
328
328
  // 6. wait for JobCompleted
329
+ // KEY INVARIANT: the real result is the WS-delivered, session-key-decrypted
330
+ // ciphertext. JobCompleted is an explorer pointer (the worker's commit-result
331
+ // tx). If the WS already produced an answer (chunks.length > 0) we MUST
332
+ // accept it even if the on-chain event hasn't landed - throwing stalled here
333
+ // wipes a delivered answer on retry, which reads as "the call returned nothing".
329
334
  const deadline = Date.now() + jobCompletedTimeoutMs;
330
335
  const jobIdTopic = (`0x${jobId.toString(16).padStart(64, "0")}`);
331
336
  let completed = null;
@@ -338,8 +343,14 @@ async function runOneAttempt(args, attempt) {
338
343
  });
339
344
  completed =
340
345
  logs.find((l) => l.topics[0] === JOB_COMPLETED_TOPIC && l.topics[1] === jobIdTopic) ?? null;
346
+ if (completed)
347
+ break;
348
+ // If the WS already delivered the answer, stop polling - no point burning
349
+ // more time on a confirmation that only serves as an explorer link.
350
+ if (chunks.length > 0)
351
+ break;
341
352
  }
342
- if (!completed) {
353
+ if (!completed && chunks.length === 0) {
343
354
  try {
344
355
  ws.close();
345
356
  }
@@ -363,7 +374,11 @@ async function runOneAttempt(args, attempt) {
363
374
  }
364
375
  return {
365
376
  answer: chunks.join(""),
366
- txs: { createSession: createTx, submitJob: submitTx, jobCompleted: completed.transactionHash },
377
+ // completed may be null when the answer arrived via the WS but JobCompleted
378
+ // hasn't landed on-chain yet. The decrypted answer is still authentic
379
+ // (session-key bound); callers can poll for the event later if they want
380
+ // the explorer-link form of the proof.
381
+ txs: { createSession: createTx, submitJob: submitTx, jobCompleted: completed?.transactionHash ?? null },
367
382
  worker: prepared.createSessionArgs.worker,
368
383
  sessionId,
369
384
  jobId,
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "lightnode-sdk",
3
- "version": "0.4.0",
3
+ "version": "0.4.2",
4
4
  "description": "Read-only TypeScript client for LightChain AI: workers, jobs, models, on-chain registration, and per-model network analytics. Independent, community-built (not an official LightChain package).",
5
5
  "type": "module",
6
6
  "main": "./dist/index.js",