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 +6 -0
- package/dist/add.js +169 -0
- package/dist/cli.js +15 -5
- package/dist/inference.d.ts +7 -2
- package/dist/inference.js +18 -3
- package/package.json +1 -1
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
|
-
:
|
|
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 === "
|
|
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
|
}
|
package/dist/inference.d.ts
CHANGED
|
@@ -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
|
|
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
|
-
|
|
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 =
|
|
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
|
-
|
|
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.
|
|
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",
|