@meffecta/agent 1.0.3 → 1.0.8

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/README.md CHANGED
@@ -1,94 +1,109 @@
1
- # @meffecta/agent
1
+ # Meffecta Agent
2
2
 
3
- Set up and operate a **Meffecta Agent** deployment a self-hosted Claude Code job runner
4
- that wakes on a schedule, a webhook, or new mail, and runs a job against a fresh clone of
5
- your own content repo.
3
+ An AI colleague that runs in **your own Google Cloud**, on **your own jobs**, against your
4
+ own systems. It wakes on a schedule, a webhook, or an email, does a piece of real work, and
5
+ reports back then goes back to sleep costing nothing.
6
6
 
7
- The agent itself is a container image (`ghcr.io/meffecta/agent`). This package is the tool
8
- that stands one up and runs it, and it is all you need: the engine's source is private,
9
- and nothing here depends on it.
7
+ Work arrives finished, rather than waiting in a chat window for you to open it — though
8
+ you can also just ask it something when you want to.
10
9
 
11
- ```bash
12
- npx @meffecta/agent steps # the whole set-up, in order
13
- npx @meffecta/agent help # every command
14
- ```
10
+ ### → [agent.meffecta.com](https://agent.meffecta.com)
15
11
 
16
- ## What it does
12
+ What it is, what you need, and the whole set-up in order. This package (`@meffecta/agent`)
13
+ is the tool that stands one up and operates it.
17
14
 
18
- Run it from your **content repo** — the directory holding `deployment.env`, `jobs/`,
19
- `worlds/` and `SYSTEM.md`. That file says which GCP project and Cloud Run service this
20
- deployment is, so a command can never quietly act on the wrong one.
15
+ ## What people run it for
21
16
 
22
- ```bash
23
- npx @meffecta/agent create-project # a GCP project
24
- npx @meffecta/agent link-billing # attach billing
25
- npx @meffecta/agent setup-infra # registry, buckets, service account, service
26
- npx @meffecta/agent set-secret AGENT_API_SECRET --random
27
- npx @meffecta/agent set-env GIT_REPO_URL=https://github.com/you/your-content-repo.git
28
- npx @meffecta/agent deploy # roll out the engine
29
- npx @meffecta/agent setup-scheduler # give it its triggers
30
- ```
17
+ A job is a page of plain-language instructions, so this is a sample rather than a menu —
18
+ the agent does what your jobs say.
31
19
 
32
- Giving it access to something Gmail, GA4, Grafana, PostHog, Cloudflare, an inbox
33
- trigger is ongoing work rather than set-up, and each has a walkthrough:
20
+ **Sales** find prospects and check them against your CRM, research each company and its
21
+ decision-maker, leave outreach drafts in the seller's own mailbox for them to send, and put
22
+ a next step on every priority account.
34
23
 
35
- ```bash
36
- npx @meffecta/agent connect # what can be connected
37
- npx @meffecta/agent connect gmail # what to do, in order, with the commands
38
- ```
24
+ **Marketing** — one weekly read across traffic, search, ads and campaigns. Website and
25
+ search audits with fixes proposed. Ad reviews, where changes happen only on request. Watches
26
+ form leads, contacts, and competitors' rankings.
39
27
 
40
- Then, day to day:
28
+ **Customer care** a support inbox that answers or hands over with the findings attached,
29
+ learns from the conversations, and keeps help pages current as things change.
41
30
 
42
- ```bash
43
- npx @meffecta/agent doctor # is anything quietly broken?
44
- npx @meffecta/agent status # revision, shape, triggers, queue
45
- npx @meffecta/agent jobs # what is registered, and what triggers it
46
- npx @meffecta/agent env # every setting (secret values never printed)
47
- npx @meffecta/agent secrets # what is stored, and what the service reads
48
- npx @meffecta/agent triggers # the Cloud Scheduler jobs and task queue
49
- npx @meffecta/agent logs --limit 100
50
- npx @meffecta/agent run morning-brief
51
- npx @meffecta/agent ask "how did search do last week"
52
- ```
31
+ **Compliance** — review an ISO 27001 document set against the standard, find what is overdue
32
+ or contradicts itself, and draft revisions for a person to approve — sized to your business
33
+ rather than to a template's idea of one. It never records that a control is in place: that
34
+ claim is what an audit tests.
53
35
 
54
- `doctor` is the one to run when something feels wrong. It judges rather than describes,
55
- and it is aimed at the failures that produce no error anywhere: a scaled-to-zero service
56
- with no triggers runs nothing while looking perfectly healthy, an always-on one with
57
- triggers runs every cron twice, a sweep whose cadence no longer matches the inbox poll
58
- interval checks mail on yesterday's schedule for ever, and a registry that expired the
59
- image your revision pins will fail at the next cold start rather than the next deploy.
60
- Every finding carries the command that fixes it, and it exits non-zero on a real problem
61
- so CI can run it.
36
+ **Engineering** overnight error triage with fixes proposed as pull requests, a verdict on
37
+ every proposed change, alerts traced to a likely cause, and weekly upkeep.
62
38
 
63
- ## Two things worth knowing
39
+ **Leadership** a morning brief tailored to each person's role, daily status on what is
40
+ live and in use, and weekly reviews that stay honest about flat weeks.
64
41
 
65
- **The version you install is the engine you deploy.** Each release records the engine build
66
- published alongside it, and `deploy` defaults to that image. Tooling and engine move
67
- together, so a script can never provision the wrong shape for the engine it is aimed at.
68
- `meffecta-agent version` tells you which pair you have.
42
+ **Assistant** email it anything: a lookup, a draft, a booking, a question. One calendar
43
+ across Google and Microsoft 365. Inbox digests and the week ahead.
44
+
45
+ ## What it can reach
69
46
 
70
- **Secret values are never printed.** `secrets` lists what exists and whether the service
71
- reads it; `env` names secret-backed settings without resolving them. Setting one takes the
72
- value from a pipe, a file, or a hidden prompt — never from your shell history.
47
+ Shipped with the engine, pointed at your accounts by your jobs:
73
48
 
74
- ## Requirements
49
+ | | Reached out of the box |
50
+ |---|---|
51
+ | **Mail & calendar** | Gmail, Microsoft 365, Google Calendar |
52
+ | **Documents** | Drive, Sheets, Docs, Slides |
53
+ | **Your numbers** | GA4, Search Console, Google Ads, PostHog, Ahrefs |
54
+ | **Your data** | BigQuery, Elasticsearch, MongoDB — and logs from Grafana or Coralogix |
55
+ | **The market** | Google's live search results — real rankings, not a web search |
56
+ | **CRM** | HubSpot, reading and writing as separate abilities |
57
+ | **Work & content** | Linear issues and cycles; Webflow pages and CMS, staged before anything goes public |
58
+ | **Slack** | Posts, threads and reactions — and answers when someone @-mentions it |
59
+ | **Infrastructure** | Cloudflare, and a real headless browser for anything without an API |
60
+ | **Your code** | any Git repo as the working directory, with pull requests as the output |
75
61
 
76
- `gcloud` (authenticated), `docker` (to mirror engine images into your registry), `git`, and
77
- Node 20+ — Node 24+ if you also run the `claude` CLI to mint the token. macOS or Linux.
62
+ Anything else is a skill you add to your own repo — the engine never needs to know about it.
78
63
 
79
- ## Developing it
64
+ ## How it works
80
65
 
81
- From a checkout of the engine repo:
66
+ Your **content repo** holds the jobs, the facts about your business, and the register of
67
+ which systems this deployment may touch. Every run clones it fresh. Push a changed prompt
68
+ and the next run uses it; there is nothing to build and nothing to deploy.
69
+
70
+ Jobs are triggered by a **schedule**, a **webhook**, or **an email to an address you name**
71
+ — and a job that runs out of time can hand the rest to a follow-up run of itself. Each job
72
+ keeps its own memory across runs, and every prompt, result and transcript lands in a
73
+ write-once audit bucket.
74
+
75
+ ## Built to be trusted with the keys
76
+
77
+ It runs in **your** GCP project, on **your** credentials, billed to **your** Claude
78
+ subscription. Nothing routes through Meffecta.
79
+
80
+ A job reaches only the systems your register lists, and reading and changing are separate
81
+ abilities — a reporting job loads the reader and never the writer.
82
+
83
+ One shipped skill exists to change a paid account, Google Ads, and it is the most
84
+ constrained thing here: every change is validated against the live account first and only
85
+ applied on a second, explicit run; budgets can only go *down*; a campaign can be paused but
86
+ never deleted; and the limits live in the script, not in the instructions, so no wording can
87
+ talk its way past them.
88
+
89
+ A job reachable by email must say who may reach it, or it refuses everyone — an omission is
90
+ not consent. Secret values are never printed, by any command.
91
+
92
+ ## Operating it
82
93
 
83
94
  ```bash
84
- cd cli && npm link # `meffecta-agent` now runs the working tree
95
+ npx @meffecta/agent steps # the set-up, in order
96
+ npx @meffecta/agent help # every command — each also takes --help
97
+ npx @meffecta/agent doctor # the one to run when something feels wrong
85
98
  ```
86
99
 
87
- The scripts and docs are vendored into this package only at pack time, so a linked
88
- checkout falls back to the engine repo's own copies edit either half and the next command
89
- picks it up. `npm rm -g @meffecta/agent` undoes it.
100
+ Run commands from your content repo the directory holding `deployment.env`, which says
101
+ which project and service this deployment is, so a command can never act on the wrong one.
102
+
103
+ **The version you install is the engine you deploy.** Each release records the engine build
104
+ published alongside it, and `deploy` defaults to that image, so tooling and engine cannot
105
+ drift apart. `meffecta-agent version` tells you which pair you have.
90
106
 
91
- ## Documentation
107
+ ## Licence
92
108
 
93
- `npx @meffecta/agent help` lists every command; `npx @meffecta/agent steps` is the whole
94
- set-up in order, with the command for each step. Every command takes `--help`.
109
+ UNLICENSED for use by Meffecta deployments.
@@ -1,6 +1,8 @@
1
1
  #!/usr/bin/env node
2
+ import { open as openAnalytics } from "../lib/analytics.js";
2
3
  import { COMMANDS, cliVersion, engineTag, GROUPS } from "../lib/commands.js";
3
4
  import { loadDeployment, UserError } from "../lib/config.js";
5
+ import { installMethod } from "../lib/version.js";
4
6
 
5
7
  /**
6
8
  * One entry point for everything an operator does to a Meffecta Agent deployment.
@@ -32,13 +34,15 @@ const STEPS = [
32
34
  ],
33
35
  [
34
36
  "init",
35
- "Create your content repo — jobs/, worlds/, SYSTEM.md, ENVIRONMENT.md and a deployment.env\n in it. Run everything from there; it is what says which deployment you mean.",
37
+ "Create your content repo — jobs/, SYSTEM.md, systems/, and a deployment.env in it.\n Run everything from there; it is what says which deployment you mean.",
36
38
  ],
37
- ["setup-infra", "Provision GCP and the Cloud Run service shell."],
39
+ ["setup-infra", "Provision GCP, the run queue, and the Cloud Run service shell.\n --dry-run shows it first."],
38
40
  ["set-secret", "AGENT_API_SECRET --random, CLAUDE_CODE_OAUTH_TOKEN, GITHUB_TOKEN."],
39
41
  ["set-env", "GIT_REPO_URL=… — the service will not boot without it."],
40
- ["deploy", "Roll out the engine image."],
41
- ["setup-scheduler", "Give it its triggers. Scaled to zero, it fires nothing without them."],
42
+ [
43
+ "deploy",
44
+ "Roll out the engine image, then create its triggers — scaled to zero it fires\n nothing without them, and only the running engine knows which jobs exist.",
45
+ ],
42
46
  ["doctor", 'Check it. Then: jobs, run <job>, ask "…".'],
43
47
  ];
44
48
 
@@ -60,9 +64,10 @@ function help() {
60
64
  console.log("");
61
65
  }
62
66
  console.log(" steps The whole set-up, in order, with the command for each");
63
- console.log(" version This CLI and the engine build it deploys\n");
64
- console.log("Every command takes --help, and passes its options through to the underlying");
65
- console.log("script so `meffecta-agent deploy --dry-run` and `--tag <sha>` work as documented.\n");
67
+ console.log(" version This CLI and the engine build it deploys");
68
+ console.log(" upgrade Whether a newer one is out, and what to run\n");
69
+ console.log("Every command takes --help, and every option it documents — so");
70
+ console.log("`meffecta-agent deploy --dry-run` and `--tag <sha>` work as described there.\n");
66
71
  console.log(`Engine: ghcr.io/meffecta/agent:${tag}${pinned ? " (pinned to this release)" : ""}`);
67
72
  }
68
73
 
@@ -94,17 +99,60 @@ function version() {
94
99
 
95
100
  const [name, ...args] = process.argv.slice(2);
96
101
 
102
+ const { analytics, firstRun } = openAnalytics({ version: cliVersion() });
103
+
104
+ // The name is matched against the known commands before it is ever recorded. Whatever an
105
+ // operator actually typed is theirs — a mistyped command could be anything, including
106
+ // something they would not want leaving the machine.
107
+ const BUILT_IN = new Set(["help", "steps", "version"]);
108
+ function recorded(candidate) {
109
+ if (!candidate) {
110
+ return "help";
111
+ }
112
+ return COMMANDS.has(candidate) || BUILT_IN.has(candidate) ? candidate : "unknown";
113
+ }
114
+
115
+ const started = Date.now();
116
+
117
+ /** Record the run, flush, and leave. The only exit in this file. */
118
+ async function finish(code, outcome, extra = {}) {
119
+ analytics.capture("cli_command", {
120
+ command: recorded(name),
121
+ outcome,
122
+ exit_code: code,
123
+ duration_ms: Date.now() - started,
124
+ cli_version: cliVersion(),
125
+ engine_tag: engineTag().tag,
126
+ install: installMethod().kind,
127
+ node_version: process.versions.node,
128
+ platform: process.platform,
129
+ arch: process.arch,
130
+ has_deployment: Boolean(loadDeployment().PROJECT),
131
+ ...extra,
132
+ });
133
+ await analytics.flush();
134
+ process.exit(code);
135
+ }
136
+
137
+ // Said once, on the run that mints the id, and never again. A tool that phones home should
138
+ // say so on the machine it phones home from — not only in documentation the operator would
139
+ // have to go looking for. stderr, so it cannot corrupt anything reading stdout.
140
+ if (firstRun) {
141
+ console.error("(Anonymous usage analytics are on: which commands run, and whether they worked.");
142
+ console.error(" No project names, arguments or secrets. `meffecta-agent analytics` to see or stop it.)\n");
143
+ }
144
+
97
145
  if (!name || name === "help" || name === "--help" || name === "-h") {
98
146
  help();
99
- process.exit(0);
147
+ await finish(0, "ok");
100
148
  }
101
149
  if (name === "steps") {
102
150
  steps();
103
- process.exit(0);
151
+ await finish(0, "ok");
104
152
  }
105
153
  if (name === "version" || name === "--version" || name === "-v") {
106
154
  version();
107
- process.exit(0);
155
+ await finish(0, "ok");
108
156
  }
109
157
 
110
158
  const command = COMMANDS.get(name);
@@ -115,7 +163,7 @@ if (!command) {
115
163
  console.error(`Did you mean: ${near.join(", ")}?`);
116
164
  }
117
165
  console.error("Run `meffecta-agent help` for the list.");
118
- process.exit(2);
166
+ await finish(2, "unknown-command");
119
167
  }
120
168
 
121
169
  try {
@@ -128,11 +176,15 @@ try {
128
176
  args.push("--tag", tag);
129
177
  }
130
178
  }
131
- process.exit((await command.handler(args)) ?? 0);
179
+ const code = (await command.handler(args)) ?? 0;
180
+ await finish(code, code === 0 ? "ok" : "failed");
132
181
  } catch (err) {
133
182
  if (err instanceof UserError) {
134
183
  console.error(err.message);
135
- process.exit(1);
184
+ await finish(1, "user-error");
136
185
  }
186
+ // The CLASS, never the message: messages quote project ids, paths and gcloud output.
187
+ analytics.capture("cli_crash", { command: recorded(name), error_kind: err?.constructor?.name ?? "Error" });
188
+ await analytics.flush();
137
189
  throw err;
138
190
  }
@@ -5,13 +5,13 @@
5
5
  # in Secret Manager), and a fresh clone of the content repo is then everything an
6
6
  # operator needs.
7
7
  #
8
- # Copy it there once:
9
- # cp deployment.env.example /path/to/your-content-repo/deployment.env
8
+ # Start one there:
9
+ # cd /path/to/your-content-repo && meffecta-agent init
10
10
  #
11
- # Then operate from the content repo, running the engine's scripts by path:
12
- # cd /path/to/your-content-repo
13
- # /path/to/agent/scripts/set-secret.sh AGENT_API_SECRET --random
14
- # /path/to/agent/scripts/deploy.sh
11
+ # Then run everything from that directory each command reads this file to know which
12
+ # deployment you mean:
13
+ # meffecta-agent set-secret AGENT_API_SECRET --random
14
+ # meffecta-agent deploy
15
15
 
16
16
  # The GCP *project ID* — check it with `gcloud projects list`, as the console may have
17
17
  # suffixed it and it need not match the project's display name.
@@ -23,6 +23,17 @@ SERVICE=acme-agent
23
23
  # Docker repo in that project, holding images mirrored from GHCR.
24
24
  ARTIFACT_REPO=acme-agent-images
25
25
 
26
+ # How the engine image reaches Cloud Run, which can only pull from Artifact Registry.
27
+ # proxy (default) ARTIFACT_REPO is a remote repository in front of ghcr.io. AR fetches
28
+ # the public engine image on demand — nothing is copied, and no docker is needed
29
+ # on your machine at any point.
30
+ # mirror the older path: pull from ghcr.io here, retag, push. Needs docker running and
31
+ # `gcloud auth configure-docker <region>-docker.pkg.dev`, and pushes several GB
32
+ # per engine version. Use it only if your policy forbids AR reaching the internet.
33
+ # A repository's mode is fixed when it is created, so changing this means a new
34
+ # ARTIFACT_REPO name and another setup-infrastructure run.
35
+ REGISTRY_MODE=proxy
36
+
26
37
  REGION=europe-west1
27
38
 
28
39
  # Optional. Unset = whichever account gcloud is logged in as. Set it when you work across
@@ -35,14 +46,16 @@ REGION=europe-west1
35
46
  # Optional. Where published engine images come from.
36
47
  # GHCR_IMAGE=ghcr.io/meffecta/agent
37
48
 
38
- # Optional. The runtime shape deploy.sh asserts on every rollout, so the deployed service
49
+ # Optional. The runtime shape asserted on every rollout, so the deployed service
39
50
  # matches this file rather than whatever was last changed by hand. The defaults below are
40
- # what setup-infrastructure.sh provisions, so leaving them out is the same as setting them.
51
+ # what `meffecta-agent setup-infra` provisions, so leaving them out is the same as setting
52
+ # them.
41
53
  #
42
54
  # SCALING picks one of two coherent pairs, and the choice is not cosmetic:
43
- # scale-to-zero min-instances=0 + CPU billed per request. Costs nothing between jobs,
44
- # and requires setup-scheduler.sh — the service has no CPU of its own to
45
- # run a timer with, so everything must arrive as a request.
55
+ # scale-to-zero min-instances=0 + CPU billed per request. Costs nothing between jobs.
56
+ # The service has no CPU of its own to run a timer with, so everything
57
+ # arrives as a request: `setup-infra` creates the queue those arrive on,
58
+ # and `deploy` creates the schedules that send them.
46
59
  # always-on min-instances=1 + CPU always allocated, for a deployment that drives
47
60
  # itself with in-process timers and has no Cloud Scheduler triggers.
48
61
  # Running scale-to-zero without triggers is a service that does nothing; running always-on
package/engine.json CHANGED
@@ -1,4 +1,4 @@
1
1
  {
2
- "engineTag": "1.0.3",
3
- "builtFrom": "f784051bc092c57aa37b4e9c44e06ee8f368f8f7"
2
+ "engineTag": "1.0.8",
3
+ "builtFrom": "09dbb9d808cafb8b06c0282602f1ca20914e3e6d"
4
4
  }
@@ -0,0 +1,262 @@
1
+ // Anonymous usage analytics, so the set-up can be improved by evidence rather than guess.
2
+ //
3
+ // This CLI runs on other people's machines, in their cloud accounts, holding their
4
+ // credentials. That earns a stricter standard than a web app's:
5
+ //
6
+ // - it never blocks — one bounded POST at exit, and the command's own work is done
7
+ // - it never throws — every path swallows its errors; analytics cannot fail a deploy
8
+ // - it never fires off — disabled, in CI, or with no key, capture() is a no-op
9
+ // - it never carries content — see WHAT IS SENT below; the list is exhaustive on purpose
10
+ //
11
+ // Dependency-free by necessity and by choice: the package has no runtime dependencies, and
12
+ // a tool holding someone's cloud credentials is a poor place for a supply chain. That means
13
+ // speaking PostHog's capture API directly rather than pulling in posthog-node.
14
+ //
15
+ // WHAT IS SENT, in full:
16
+ // which command ran (from the fixed list — never a word the operator typed), whether it
17
+ // succeeded, how long it took, this CLI's version and the engine tag it deploys, Node
18
+ // version, OS and architecture, how the CLI was installed, and whether a deployment.env
19
+ // was found. Plus a random id, minted here, so repeat runs group as one install.
20
+ //
21
+ // WHAT IS NEVER SENT:
22
+ // the GCP project or service name, the region, repo URLs, email or account names, file
23
+ // paths, command arguments, secret names or values, job names, prompts, or any error
24
+ // MESSAGE — only the error's class. Messages are the leak: they routinely quote a
25
+ // project id, a path, or a line of gcloud output.
26
+ //
27
+ // Not even a hashed project id. It would be genuinely useful — it would let one deployment
28
+ // be followed across machines — but a hash of a short, guessable string is pseudonymous at
29
+ // best, and the anonymous install id already answers most of the same questions. If that
30
+ // correlation is ever wanted it should be a deliberate decision, disclosed on its own, not
31
+ // something that arrived quietly inside a telemetry patch.
32
+
33
+ import { randomUUID } from "node:crypto";
34
+ import { mkdirSync, readFileSync, renameSync, writeFileSync } from "node:fs";
35
+ import { homedir } from "node:os";
36
+ import { dirname, join } from "node:path";
37
+
38
+ /**
39
+ * PostHog *project* key — write-only, designed to ship inside a client, and public by
40
+ * nature: this package is on npm, so treat it as published. It grants event ingestion and
41
+ * nothing else. Empty means nothing is ever sent.
42
+ */
43
+ const DEFAULT_KEY = "phc_pyUMjGAJSMJtStPULeiiutucEiN6qkRhuKr5RJVuEXqX";
44
+
45
+ /** A project key belongs to one region; this one is EU cloud. */
46
+ const DEFAULT_HOST = "https://eu.i.posthog.com";
47
+
48
+ const LIB = "meffecta-agent-cli";
49
+
50
+ /** Analytics is never worth a hang, and never worth a visible pause. */
51
+ const FLUSH_TIMEOUT_MS = 1500;
52
+
53
+ /** An env var means "on" unless it is empty, "0", or "false". */
54
+ function truthy(value) {
55
+ const v = (value ?? "").trim().toLowerCase();
56
+ return v !== "" && v !== "0" && v !== "false";
57
+ }
58
+
59
+ /** Exported so tests can neutralise every one of them, rather than the two they remember. */
60
+ export const CI_VARS = [
61
+ "CI",
62
+ "CONTINUOUS_INTEGRATION",
63
+ "GITHUB_ACTIONS",
64
+ "GITLAB_CI",
65
+ "CIRCLECI",
66
+ "TRAVIS",
67
+ "BUILDKITE",
68
+ "JENKINS_URL",
69
+ "TEAMCITY_VERSION",
70
+ "BITBUCKET_BUILD_NUMBER",
71
+ ];
72
+
73
+ export function isCI(env = process.env) {
74
+ return CI_VARS.some((key) => truthy(env[key]));
75
+ }
76
+
77
+ /**
78
+ * Where the choice and the anonymous id live. Machine-global, NOT beside deployment.env:
79
+ * the opt-out is a decision by the person at the keyboard and should cover every
80
+ * deployment they operate, rather than being made again per content repo — and it must
81
+ * never end up committed to one.
82
+ */
83
+ export function configDir(env = process.env) {
84
+ const base = env.XDG_CONFIG_HOME?.trim() || join(homedir(), ".config");
85
+ return join(base, "meffecta-agent");
86
+ }
87
+
88
+ export function configPath(env = process.env) {
89
+ return join(configDir(env), "config.json");
90
+ }
91
+
92
+ /** Missing or corrupt reads as empty. Never throws — this is on the startup path. */
93
+ export function readConfig(env = process.env) {
94
+ try {
95
+ const parsed = JSON.parse(readFileSync(configPath(env), "utf8"));
96
+ return parsed && typeof parsed === "object" ? parsed : {};
97
+ } catch {
98
+ return {};
99
+ }
100
+ }
101
+
102
+ /** Merge and persist atomically, so a crash mid-write cannot corrupt the choice. */
103
+ export function writeConfig(patch, env = process.env) {
104
+ const path = configPath(env);
105
+ const next = { ...readConfig(env), ...patch };
106
+ try {
107
+ mkdirSync(dirname(path), { recursive: true });
108
+ const tmp = `${path}.${process.pid}.tmp`;
109
+ writeFileSync(tmp, `${JSON.stringify(next, null, 2)}\n`);
110
+ renameSync(tmp, path);
111
+ } catch {
112
+ // A read-only home directory is not a reason to fail a deploy. The consequence is a
113
+ // fresh id next run, which is the harmless direction to fail in.
114
+ }
115
+ return next;
116
+ }
117
+
118
+ /**
119
+ * On or off, and why. Opt-out by default, with several ways to say no and one explicit
120
+ * way to say yes:
121
+ *
122
+ * 1. DO_NOT_TRACK=1 the cross-tool standard — always wins
123
+ * 2. MEFFECTA_ANALYTICS_DISABLED=1 this tool's own kill switch
124
+ * 3. config "disabled" `meffecta-agent analytics disable`
125
+ * 4. config "enabled" explicit yes — beats CI detection
126
+ * 5. a CI environment off: a pipeline is nobody's choice to make
127
+ * 6. otherwise on
128
+ *
129
+ * Pure, so every rule is testable without touching a disk or a network.
130
+ */
131
+ export function decideAnalytics({ config = {}, env = process.env } = {}) {
132
+ if (truthy(env.DO_NOT_TRACK)) {
133
+ return { enabled: false, reason: "do-not-track" };
134
+ }
135
+ if (truthy(env.MEFFECTA_ANALYTICS_DISABLED)) {
136
+ return { enabled: false, reason: "env-disabled" };
137
+ }
138
+ if (config.analytics === "disabled") {
139
+ return { enabled: false, reason: "config-disabled" };
140
+ }
141
+ if (config.analytics === "enabled") {
142
+ return { enabled: true, reason: "config-enabled" };
143
+ }
144
+ if (isCI(env)) {
145
+ return { enabled: false, reason: "ci" };
146
+ }
147
+ return { enabled: true, reason: "default" };
148
+ }
149
+
150
+ /** Endpoint and key, with env overrides for anyone who wants to point it elsewhere. */
151
+ export function resolvePosthog(env = process.env) {
152
+ return {
153
+ host: (env.MEFFECTA_POSTHOG_HOST?.trim() || DEFAULT_HOST).replace(/\/+$/, ""),
154
+ key: env.MEFFECTA_POSTHOG_KEY?.trim() ?? DEFAULT_KEY,
155
+ };
156
+ }
157
+
158
+ /** The default transport: one fetch, aborted rather than allowed to hang. */
159
+ const defaultSender = async (url, body, timeoutMs) => {
160
+ const controller = new AbortController();
161
+ const timer = setTimeout(() => controller.abort(), timeoutMs);
162
+ try {
163
+ await fetch(url, {
164
+ method: "POST",
165
+ headers: { "content-type": "application/json" },
166
+ body: JSON.stringify(body),
167
+ signal: controller.signal,
168
+ });
169
+ } finally {
170
+ clearTimeout(timer);
171
+ }
172
+ };
173
+
174
+ export class Analytics {
175
+ #enabled;
176
+ #id;
177
+ #key;
178
+ #host;
179
+ #version;
180
+ #send;
181
+ #queue = [];
182
+
183
+ constructor({ enabled = false, anonymousId = "", key = "", host = "", version = "", send } = {}) {
184
+ this.#enabled = enabled;
185
+ this.#id = anonymousId;
186
+ this.#key = key;
187
+ this.#host = host;
188
+ this.#version = version;
189
+ this.#send = send ?? defaultSender;
190
+ }
191
+
192
+ /** Guaranteed to do nothing — the fallback whenever anything is unclear. */
193
+ static off() {
194
+ return new Analytics();
195
+ }
196
+
197
+ /** Only true when a real, sendable pipeline exists. */
198
+ get active() {
199
+ return this.#enabled && this.#key !== "" && this.#id !== "";
200
+ }
201
+
202
+ /** Queue an event. A no-op when off, and never throws. */
203
+ capture(event, properties = {}) {
204
+ if (!this.active) {
205
+ return;
206
+ }
207
+ this.#queue.push({ event, properties, timestamp: new Date().toISOString() });
208
+ }
209
+
210
+ /** Send what is queued. Bounded, and swallows everything. */
211
+ async flush() {
212
+ if (!this.active || this.#queue.length === 0) {
213
+ return;
214
+ }
215
+ const batch = this.#queue.splice(0, this.#queue.length);
216
+ try {
217
+ await this.#send(
218
+ `${this.#host}/batch/`,
219
+ {
220
+ api_key: this.#key,
221
+ batch: batch.map((e) => ({
222
+ event: e.event,
223
+ distinct_id: this.#id,
224
+ timestamp: e.timestamp,
225
+ properties: { ...e.properties, $lib: LIB, $lib_version: this.#version },
226
+ })),
227
+ },
228
+ FLUSH_TIMEOUT_MS,
229
+ );
230
+ } catch {
231
+ // Analytics must never surface an error to the operator.
232
+ }
233
+ }
234
+ }
235
+
236
+ /**
237
+ * Resolve everything and hand back a client ready to use.
238
+ *
239
+ * The id is minted on the first run where analytics is on, and only then: a machine that
240
+ * has opted out never gets an identifier written to it at all. `firstRun` is true for the
241
+ * run that mints it, which is the one run that says so out loud.
242
+ */
243
+ export function open({ env = process.env, version = "unknown", send } = {}) {
244
+ const config = readConfig(env);
245
+ const decision = decideAnalytics({ config, env });
246
+ if (!decision.enabled) {
247
+ return { analytics: Analytics.off(), decision, config, firstRun: false };
248
+ }
249
+ const { host, key } = resolvePosthog(env);
250
+ let anonymousId = config.anonymousId;
251
+ const firstRun = !anonymousId;
252
+ if (firstRun) {
253
+ anonymousId = randomUUID();
254
+ writeConfig({ anonymousId }, env);
255
+ }
256
+ return {
257
+ analytics: new Analytics({ enabled: true, anonymousId, key, host, version, send }),
258
+ decision,
259
+ config: { ...config, anonymousId },
260
+ firstRun,
261
+ };
262
+ }