moria-im 0.1.0 → 0.1.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.
Files changed (3) hide show
  1. package/moria.mjs +109 -20
  2. package/package.json +1 -1
  3. package/npm-shrinkwrap.json +0 -1830
package/moria.mjs CHANGED
@@ -364,6 +364,11 @@ function validatePresence(m) {
364
364
  if (b.v !== 1) errs.push("v must be 1");
365
365
  if (b.state !== "typing" && b.state !== "idle") errs.push("state must be typing or idle");
366
366
  if (b.ttlMs !== void 0 && !(typeof b.ttlMs === "number" && Number.isInteger(b.ttlMs) && b.ttlMs > 0 && b.ttlMs <= PRESENCE_TTL_MS)) errs.push(`ttlMs must be an integer 1..${PRESENCE_TTL_MS}`);
367
+ if (b.stage !== void 0) {
368
+ if (b.stage !== "received" && b.stage !== "working") errs.push("stage must be received or working");
369
+ if (b.state !== "typing") errs.push("stage goes only with state typing");
370
+ }
371
+ if (b.startedAt !== void 0 && !(typeof b.startedAt === "number" && Number.isInteger(b.startedAt) && b.startedAt > 0)) errs.push("startedAt must be a positive integer (ms)");
367
372
  return errs;
368
373
  }
369
374
  function encodePresence(m) {
@@ -804,7 +809,7 @@ init_src();
804
809
  import { randomBytes as randomBytes3 } from "node:crypto";
805
810
  import { existsSync as existsSync5, mkdirSync as mkdirSync6, rmSync as rmSync2, writeFileSync as writeFileSync6 } from "node:fs";
806
811
  import { homedir as homedir2, hostname as hostname2, platform as platform2 } from "node:os";
807
- import { join as join4 } from "node:path";
812
+ import { join as join4, resolve } from "node:path";
808
813
  import { parseArgs } from "node:util";
809
814
  import { generatePrivateKey as generatePrivateKey2, privateKeyToAccount as privateKeyToAccount4 } from "viem/accounts";
810
815
 
@@ -829,10 +834,17 @@ function networkFor(host) {
829
834
  }
830
835
  var DEFAULT_NETWORK = networkFor(process.env.MORIA_NETWORK || "moria.im");
831
836
  var PUBLIC_NETWORK = networkFor("moria.im");
832
- function enrollInstruction(token, runtime, n) {
837
+ var shellQuote = (s) => /^[\p{L}\p{N}._\-/:@+=,]+$/u.test(s) ? s : `'${s.replace(/'/g, `'\\''`)}'`;
838
+ function enrollCommand(token, runtime, n, d = {}) {
833
839
  const pub = n.apiUrl === PUBLIC_NETWORK.apiUrl && n.historySyncUrl === PUBLIC_NETWORK.historySyncUrl && n.directoryUrl === PUBLIC_NETWORK.directoryUrl;
834
840
  const flags = pub ? "" : ` --directory ${n.directoryUrl} --api ${n.apiUrl} --history ${n.historySyncUrl}`;
835
- return `Join my Moria chats as an agent, following https://get.moria.im/agents.md. If the moria command is missing, install it: curl -fsSL https://get.moria.im/moria | sh. Then run: moria agent enroll --token ${token} --name <your name> --runtime ${runtime} --model <your model>${flags}. The code expires in 15 minutes. Tell me the 4 emoji it shows.`;
841
+ const name = d.name?.trim() && AGENT_NAME_RE.test(d.name.trim()) ? d.name.trim() : "<your name>";
842
+ const model = d.model?.trim() ? shellQuote(d.model.trim()) : "<your model>";
843
+ const bio = d.bio?.trim() ? ` --bio ${shellQuote(d.bio.trim().slice(0, 280))}` : "";
844
+ return `moria agent enroll --token ${token} --name ${name} --runtime ${runtime} --model ${model}${bio}${flags}`;
845
+ }
846
+ function enrollInstruction(token, runtime, n, d = {}) {
847
+ return `Join my Moria chats as an agent, following https://get.moria.im/agents.md. If the moria command is missing, install it: curl -fsSL https://get.moria.im/moria | sh (or: npm install -g moria-im). Then run: ${enrollCommand(token, runtime, n, d)}. The code expires in 15 minutes. Tell me the 4 emoji it shows.`;
836
848
  }
837
849
  var configPath = () => join(MORIA_HOME, "config.json");
838
850
  var dataDir = () => join(MORIA_HOME, "data");
@@ -1754,15 +1766,15 @@ async function serveMcp(cfg) {
1754
1766
  }, async ({ timeoutSec }) => {
1755
1767
  const mine = () => queue.filter((e) => e.addressedToMe);
1756
1768
  if (!mine().length) {
1757
- await new Promise((resolve) => {
1769
+ await new Promise((resolve2) => {
1758
1770
  const t = setTimeout(() => {
1759
1771
  wake = null;
1760
- resolve();
1772
+ resolve2();
1761
1773
  }, (timeoutSec ?? 60) * 1e3);
1762
1774
  wake = () => {
1763
1775
  clearTimeout(t);
1764
1776
  wake = null;
1765
- resolve();
1777
+ resolve2();
1766
1778
  };
1767
1779
  });
1768
1780
  }
@@ -2106,6 +2118,7 @@ async function serveLocal(opts = {}) {
2106
2118
  a.operatorName = people.get(op)?.name ?? names.get(op) ?? a.operatorHandle;
2107
2119
  }
2108
2120
  const typing = {};
2121
+ const progress = {};
2109
2122
  const now = Date.now();
2110
2123
  for (const c of conversations) {
2111
2124
  const raw = msgs.get(c.id) ?? [];
@@ -2115,7 +2128,9 @@ async function serveLocal(opts = {}) {
2115
2128
  const last = [...raw].reverse().find((m) => m.senderInboxId === aid);
2116
2129
  if (!last || !isPresenceType(last.contentType) || !last.content || typeof last.content !== "object") return false;
2117
2130
  const p = last.content;
2118
- return p.state === "typing" && now < Number(last.sentAtNs / 1000000n) + (p.ttlMs ?? 3e4);
2131
+ const on = p.state === "typing" && now < Number(last.sentAtNs / 1000000n) + (p.ttlMs ?? 3e4);
2132
+ if (on && (p.stage === "received" || p.stage === "working")) (progress[c.id] ??= {})[aid] = { stage: p.stage, ...p.startedAt ? { startedAt: p.startedAt } : {} };
2133
+ return on;
2119
2134
  });
2120
2135
  if (active.length) typing[c.id] = active;
2121
2136
  }
@@ -2123,7 +2138,7 @@ async function serveLocal(opts = {}) {
2123
2138
  conversations.sort((a, b) => lastAt(b) - lastAt(a));
2124
2139
  if (Object.keys(typing).length) setTimeout(() => void rebuild().catch(() => {
2125
2140
  }), 30500);
2126
- doc = { me, project: projectName ? { id: "local", name: projectName, emoji: projectEmoji } : null, people: [...people.values()], agents: [...agents.values()], hosts: hosts(), conversations, messages, tasks, approvals, enrollments, waitingFor, invitesCreated, typing };
2141
+ doc = { me, project: projectName ? { id: "local", name: projectName, emoji: projectEmoji } : null, people: [...people.values()], agents: [...agents.values()], hosts: hosts(), conversations, messages, tasks, approvals, enrollments, waitingFor, invitesCreated, typing, progress };
2127
2142
  bump();
2128
2143
  const unknown = [.../* @__PURE__ */ new Set([...people.keys(), ...[...agents.values()].map((a) => a.operatorId)])].filter((id) => id !== meId && !handles.has(id));
2129
2144
  if (unknown.length && dir) void (async () => {
@@ -2384,7 +2399,7 @@ async function serveLocal(opts = {}) {
2384
2399
  const { d } = need();
2385
2400
  const r = await d.createEnrollment(opts.projectTag ?? projectName ?? "project", 900);
2386
2401
  const n = cfg.network;
2387
- const instruction = enrollInstruction(r.token, RUNTIME_OF[b.app ?? "Other"] ?? "other", n);
2402
+ const instruction = enrollInstruction(r.token, RUNTIME_OF[b.app ?? "Other"] ?? "other", n, { name: b.name, model: b.model, bio: b.bio });
2388
2403
  waitingFor = b.app ?? "Other";
2389
2404
  if (enrollTimer) clearInterval(enrollTimer);
2390
2405
  const poll = async () => {
@@ -2523,12 +2538,12 @@ async function serveLocal(opts = {}) {
2523
2538
  res.writeHead(status2, { "content-type": "application/json" });
2524
2539
  res.end(JSON.stringify(body));
2525
2540
  };
2526
- const readBody = (req) => new Promise((resolve, reject) => {
2541
+ const readBody = (req) => new Promise((resolve2, reject) => {
2527
2542
  let t = "";
2528
2543
  req.on("data", (c) => t += c);
2529
2544
  req.on("end", () => {
2530
2545
  try {
2531
- resolve(t ? JSON.parse(t) : {});
2546
+ resolve2(t ? JSON.parse(t) : {});
2532
2547
  } catch (e) {
2533
2548
  reject(new HttpError(400, "bad json"));
2534
2549
  }
@@ -2624,6 +2639,7 @@ import { Dm as Dm3, Group as Group4 } from "@xmtp/node-sdk";
2624
2639
  // src/llm.ts
2625
2640
  import Anthropic from "@anthropic-ai/sdk";
2626
2641
  import OpenAI from "openai";
2642
+ import { spawn } from "node:child_process";
2627
2643
  function makeLlm(env, ctx = {}) {
2628
2644
  const provider = process.env.LLM_PROVIDER || env.LLM_PROVIDER || "openai";
2629
2645
  const botName = env.BOT_NAME ?? "";
@@ -2639,6 +2655,7 @@ function makeLlm(env, ctx = {}) {
2639
2655
  const m = mockLlm(botName, ctx.otherBots ?? []);
2640
2656
  return async (sy, us) => ({ text: await m(sy, us), usage: { in: us.length >> 2, out: 8 } });
2641
2657
  }
2658
+ if (provider === "claude-code") return claudeCodeLlm(env, clean);
2642
2659
  const model = required(env, "LLM_MODEL");
2643
2660
  const maxTokens = Number(env.LLM_MAX_TOKENS ?? 1024);
2644
2661
  if (provider === "anthropic") {
@@ -2680,6 +2697,59 @@ function makeLlm(env, ctx = {}) {
2680
2697
  return { text: clean(res.choices[0]?.message?.content ?? ""), ...u ? { usage: { in: u.prompt_tokens, out: u.completion_tokens } } : {} };
2681
2698
  };
2682
2699
  }
2700
+ function claudeCodeLlm(env, clean = (t) => t.trim()) {
2701
+ const bin = process.env.CLAUDE_BIN || env.CLAUDE_BIN || "claude";
2702
+ const cwd = required(env, "LLM_WORKDIR");
2703
+ const toolList = env.LLM_TOOLS || "Read Grep Glob";
2704
+ const tools = toolList.split(toolList.includes(",") ? /\s*,\s*/ : /\s+/).filter(Boolean);
2705
+ const model = env.LLM_MODEL ?? "";
2706
+ const timeoutMs = Number(env.LLM_TIMEOUT_MS ?? 6e5);
2707
+ return (system, user) => new Promise((resolve2, reject) => {
2708
+ const args = [
2709
+ "-p",
2710
+ "--output-format",
2711
+ "json",
2712
+ "--no-session-persistence",
2713
+ "--permission-mode",
2714
+ "dontAsk",
2715
+ "--strict-mcp-config",
2716
+ "--append-system-prompt",
2717
+ system
2718
+ ];
2719
+ if (/^(claude|opus|sonnet|haiku|fable)/i.test(model)) args.push("--model", model);
2720
+ args.push("--allowedTools", ...tools);
2721
+ const child = spawn(bin, args, { cwd, stdio: ["pipe", "pipe", "pipe"] });
2722
+ let out2 = "", err = "";
2723
+ const timer = setTimeout(() => {
2724
+ child.kill("SIGTERM");
2725
+ reject(new Error(`claude -p took longer than ${timeoutMs / 1e3}s`));
2726
+ }, timeoutMs);
2727
+ child.stdout.on("data", (d) => {
2728
+ out2 += d;
2729
+ });
2730
+ child.stderr.on("data", (d) => {
2731
+ err += d;
2732
+ });
2733
+ child.on("error", (e) => {
2734
+ clearTimeout(timer);
2735
+ reject(new Error(`could not start ${bin}: ${e.message} (is Claude Code installed?)`));
2736
+ });
2737
+ child.on("close", (code) => {
2738
+ clearTimeout(timer);
2739
+ let r;
2740
+ try {
2741
+ r = JSON.parse(out2);
2742
+ } catch {
2743
+ return reject(new Error(`claude -p exited ${code}: ${(err || out2).trim().slice(0, 300)}`));
2744
+ }
2745
+ if (r.is_error || typeof r.result !== "string") return reject(new Error(`claude -p failed (${r.subtype ?? code}): ${String(r.result ?? err).slice(0, 300)}`));
2746
+ const u = r.usage ?? {};
2747
+ const tokensIn = (u.input_tokens ?? 0) + (u.cache_creation_input_tokens ?? 0) + (u.cache_read_input_tokens ?? 0);
2748
+ resolve2({ text: clean(r.result), usage: { in: tokensIn, out: u.output_tokens ?? 0 } });
2749
+ });
2750
+ child.stdin.end(user);
2751
+ });
2752
+ }
2683
2753
  function mockLlm(botName, otherBots = []) {
2684
2754
  return async (_system, user) => {
2685
2755
  const tagged = [...user.matchAll(/<msg[^>]*\sfrom="([^"]+)"[^>]*>([\s\S]*?)<\/msg>/g)].map((m) => [m[1], unescapeXml(m[2])]);
@@ -2963,9 +3033,13 @@ Write your next message as @${this.cfg.name}. Reply with the message text only.`
2963
3033
  }
2964
3034
  const { system, user } = this.prompt(conv, reg);
2965
3035
  const t0 = Date.now();
3036
+ const presence = (p) => conv.send(encodePresence(p)).catch((e) => this.log(`presence failed ${e.message}`));
3037
+ await presence({ v: 1, state: "typing", ttlMs: 3e4, stage: "received" });
3038
+ const working = () => presence({ v: 1, state: "typing", ttlMs: 3e4, stage: "working", startedAt: t0 });
3039
+ await working();
3040
+ const heartbeat = setInterval(() => void working(), 2e4);
2966
3041
  try {
2967
- await conv.send(encodePresence({ v: 1, state: "typing", ttlMs: 3e4 })).catch((e) => this.log(`presence failed ${e.message}`));
2968
- const out2 = await this.llm(system, user);
3042
+ const out2 = await this.llm(system, user).finally(() => clearInterval(heartbeat));
2969
3043
  if (!out2.text) return this.log("empty answer, skipping");
2970
3044
  const text = out2.text.slice(0, 4e3);
2971
3045
  const body = {
@@ -3226,8 +3300,21 @@ async function agentSet() {
3226
3300
  const cfg = requireConfig();
3227
3301
  const name = argv[0];
3228
3302
  if (!name || !cfg.agents[name]) fail(`unknown agent "${name ?? ""}"`);
3229
- const { values } = parseArgs({ args: argv.slice(1), options: { limit: { type: "string" }, dm: { type: "string" }, a2a: { type: "string" }, public: { type: "string" }, bio: { type: "string" }, skills: { type: "string" }, operator: { type: "string" } }, strict: true });
3303
+ const { values } = parseArgs({ args: argv.slice(1), options: { limit: { type: "string" }, dm: { type: "string" }, a2a: { type: "string" }, public: { type: "string" }, bio: { type: "string" }, skills: { type: "string" }, operator: { type: "string" }, drive: { type: "string" }, workdir: { type: "string" }, tools: { type: "string" }, "drive-model": { type: "string" } }, strict: true });
3230
3304
  const a = cfg.agents[name];
3305
+ if (values.drive !== void 0) {
3306
+ if (values.drive === "none") {
3307
+ delete a.drive;
3308
+ out(`${name}: answered only by its own runtime again (moria mcp serve)`);
3309
+ } else if (values.drive === "claude-code") {
3310
+ if (a.provider !== "mcp") fail(`${name} is run by moria with ${a.provider}; --drive is for agents enrolled from a coding app`);
3311
+ const workdir = resolve(values.workdir ?? a.drive?.workdir ?? fail("--workdir DIR required: the project folder Claude Code works in"));
3312
+ if (!existsSync5(workdir)) fail(`--workdir: ${workdir} does not exist`);
3313
+ a.drive = { kind: "claude-code", workdir, ...values.tools ? { tools: values.tools } : a.drive?.tools ? { tools: a.drive.tools } : {}, ...values["drive-model"] ? { model: values["drive-model"] } : a.drive?.model ? { model: a.drive.model } : {} };
3314
+ out(`${name}: moria run answers its mentions with Claude Code in ${workdir} (tools: ${a.drive.tools ?? "Read Grep Glob, read-only"}).`);
3315
+ out(`Don't keep a Claude Code session with the moria MCP server open for ${name} at the same time: both would answer.`);
3316
+ } else fail("--drive must be claude-code or none");
3317
+ } else if (values.workdir || values.tools || values["drive-model"]) fail("--workdir, --tools and --drive-model go with --drive claude-code");
3231
3318
  if (values.operator !== void 0) {
3232
3319
  const v = values.operator.trim();
3233
3320
  const inbox = v === "me" ? cfg.owner.inboxId : /^[0-9a-f]{64}$/.test(v) ? v : (await directoryClient(cfg.network.directoryUrl).profileOf(v.replace(/^@/, "").toLowerCase()))?.inboxId;
@@ -3321,8 +3408,8 @@ async function run() {
3321
3408
  const { values } = parseArgs({ args: argv, options: { "legacy-text-mentions": { type: "boolean" }, "agents-may-address-agents": { type: "boolean" }, "stale-ms": { type: "string" } }, strict: true });
3322
3409
  const cfg = requireConfig();
3323
3410
  const s = await secrets();
3324
- const active = Object.values(cfg.agents).filter((a) => a.state === "active" && a.provider !== "mcp");
3325
- if (!active.length) fail('no active agents to run (mcp-bridged agents are driven by their runtime via "moria mcp serve")');
3411
+ const active = Object.values(cfg.agents).filter((a) => a.state === "active" && (a.provider !== "mcp" || a.drive));
3412
+ if (!active.length) fail("no active agents to run (agents from coding apps answer through their app, or: moria agent set NAME --drive claude-code --workdir DIR)");
3326
3413
  const localAgents = Object.fromEntries(Object.values(cfg.agents).filter((a) => a.inboxId).map((a) => [a.name, a.inboxId]));
3327
3414
  const runners = [];
3328
3415
  const hostLog = (l) => out((/* @__PURE__ */ new Date()).toISOString(), "[moria]", l);
@@ -3331,7 +3418,7 @@ async function run() {
3331
3418
  const walletKey = s.get(secretNames.agentWallet(a.name));
3332
3419
  const dbKeyHex = s.get(secretNames.agentDbKey(a.name));
3333
3420
  if (!walletKey || !dbKeyHex) throw new Error(`missing keys for ${a.name} in the ${s.kind} secrets store`);
3334
- const llm = { LLM_PROVIDER: a.provider, LLM_MODEL: a.model };
3421
+ const llm = a.drive ? { LLM_PROVIDER: a.drive.kind, LLM_WORKDIR: a.drive.workdir, LLM_MODEL: a.drive.model ?? "", ...a.drive.tools ? { LLM_TOOLS: a.drive.tools } : {} } : { LLM_PROVIDER: a.provider, LLM_MODEL: a.model };
3335
3422
  if (a.baseUrl) llm.LLM_BASE_URL = a.baseUrl;
3336
3423
  const apiKey = s.get(secretNames.agentApiKey(a.name));
3337
3424
  if (apiKey) llm[a.provider === "anthropic" ? "ANTHROPIC_API_KEY" : "LLM_API_KEY"] = apiKey;
@@ -3384,13 +3471,14 @@ async function enroll() {
3384
3471
  const cfg = requireConfig();
3385
3472
  const dir = directoryClient(cfg.network.directoryUrl, await ownerKey());
3386
3473
  if (sub === "create") {
3387
- const { values } = parseArgs({ args: rest, options: { project: { type: "string" }, ttl: { type: "string" } }, strict: true });
3474
+ const { values } = parseArgs({ args: rest, options: { project: { type: "string" }, ttl: { type: "string" }, name: { type: "string" }, model: { type: "string" }, bio: { type: "string" }, runtime: { type: "string" } }, strict: true });
3475
+ if (values.name && !AGENT_NAME_RE.test(values.name)) fail("--name: 1..32 chars of a-z 0-9 _ -");
3388
3476
  const r = await dir.createEnrollment(values.project ?? "default", values.ttl ? Number(values.ttl) : 900);
3389
3477
  out(`enrollment ${r.id} (expires ${new Date(r.expiresAt).toISOString()})`);
3390
3478
  out(`token: ${r.token}`);
3391
3479
  out(`
3392
3480
  Paste into the agent's runtime:
3393
- ${enrollInstruction(r.token, "<claude-code|codex|gemini|junie|claude-desktop>", cfg.network)}`);
3481
+ ${enrollInstruction(r.token, values.runtime ?? "<claude-code|codex|gemini|junie|claude-desktop>", cfg.network, { name: values.name, model: values.model, bio: values.bio })}`);
3394
3482
  out(`Then: moria enroll list \u2192 moria enroll approve ${r.id} [--group <conversation id>]`);
3395
3483
  return;
3396
3484
  }
@@ -3641,10 +3729,11 @@ var HELP = `moria \u2014 Moria agent host
3641
3729
  moria status
3642
3730
  moria agent add --name NAME --provider openai|anthropic|mock [--model M] [--base-url URL] [--api-key KEY] [--runtime R] [--egress \u2026] [--no-think] [--handle H] [--bio TEXT] [--dm owner|anyone] [--a2a block|allow]
3643
3731
  moria agent list | pause NAME | resume NAME | remove NAME | publish NAME
3732
+ moria agent set NAME [--drive claude-code --workdir DIR [--tools "Read Grep Glob"] [--drive-model M] | --drive none]
3644
3733
  moria agent set NAME [--limit 200k/day|none] [--dm owner|anyone] [--a2a block|allow] [--public yes|no] [--bio TEXT] [--skills a,b] [--operator INBOX|@user|me]
3645
3734
  moria agent enroll --token qet_\u2026 --name NAME --runtime R --model M [--bio TEXT] [--directory URL] [--api URL] [--history URL]
3646
3735
  moria agent connect claude-code|codex|gemini|junie|claude-desktop --agent NAME
3647
- moria enroll create [--project TAG] [--ttl SEC] | list [status] | approve ID [--group GID]\u2026 | deny ID
3736
+ moria enroll create [--project TAG] [--ttl SEC] [--name N --runtime R --model M --bio TEXT] | list [status] | approve ID [--group GID]\u2026 | deny ID
3648
3737
  moria mcp serve --agent NAME
3649
3738
  moria serve --local [--port N] (loopback facade for the desktop app)
3650
3739
  moria run [--legacy-text-mentions] [--agents-may-address-agents]
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "moria-im",
3
- "version": "0.1.0",
3
+ "version": "0.1.1",
4
4
  "description": "Run your AI agents in Moria chats: local models, API models and coding agents.",
5
5
  "homepage": "https://moria.im/docs/agents.html",
6
6
  "type": "module",