diff --git a/Documentation.md b/Documentation.md
index c795966..ed79198 100644
--- a/Documentation.md
+++ b/Documentation.md
@@ -1,7 +1,7 @@
# GitAgent Documentation
> **GitAgent** — A universal git-native multimodal always-learning AI Agent
-> Version 1.3.3 | MIT License | [github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent)
+> Version 1.5.2 | MIT License | [github.com/open-gitagent/gitagent](https://github.com/open-gitagent/gitagent)
---
@@ -83,7 +83,7 @@ The installer offers four options:
curl -fsSL https://raw.githubusercontent.com/open-gitagent/gitagent/main/install.sh | bash
# Or manually
-npm update -g gitagent
+npm update -g @open-gitagent/gitagent
```
---
diff --git a/README.md b/README.md
index 526cd8d..59f1917 100644
--- a/README.md
+++ b/README.md
@@ -3,7 +3,7 @@
-
+
@@ -56,7 +56,7 @@ This will:
- Walk you through API key setup (Quick or Advanced mode)
- Launch the voice UI in your browser at `http://localhost:3333`
-> **Requirements:** Node.js 18+, npm, git
+> **Requirements:** Node.js 20+, npm, git
### Or install manually:
@@ -780,7 +780,7 @@ Your agent lives in a git repository with structured files:
### Installation & Setup
**What are the requirements?**
-Node.js 18+ (or 20+ recommended), npm, and git. Install globally with `npm install -g @open-gitagent/gitagent` (slim CLI + SDK). Add `@open-gitagent/voice` for voice mode + the web UI.
+Node.js 20+, npm, and git. Install globally with `npm install -g @open-gitagent/gitagent` (slim CLI + SDK). Add `@open-gitagent/voice` for voice mode + the web UI.
**How do I set up API keys?**
Run the installer for guided setup:
diff --git a/install.sh b/install.sh
index a2dd4ad..0e865ae 100755
--- a/install.sh
+++ b/install.sh
@@ -91,8 +91,8 @@ check_cmd npm
check_cmd git
NODE_VERSION=$(node -v | sed 's/v//' | cut -d. -f1)
-if [ "$NODE_VERSION" -lt 18 ]; then
- echo -e " ${RED}✗ Node.js 18+ required (found $(node -v))${NC}"
+if [ "$NODE_VERSION" -lt 20 ]; then
+ echo -e " ${RED}✗ Node.js 20+ required (found $(node -v))${NC}"
exit 1
fi
diff --git a/package.json b/package.json
index 00aa77c..723b197 100644
--- a/package.json
+++ b/package.json
@@ -43,7 +43,7 @@
"build": "tsc",
"dev": "tsc --watch",
"start": "node dist/index.js",
- "test": "node --test test/*.test.ts --experimental-strip-types"
+ "test": "npx tsx --test test/*.test.ts"
},
"engines": {
"node": ">=20"
diff --git a/src/__tests__/telemetry.test.ts b/src/__tests__/telemetry.test.ts
deleted file mode 100644
index 232554d..0000000
--- a/src/__tests__/telemetry.test.ts
+++ /dev/null
@@ -1,6 +0,0 @@
-import { describe, it } from "node:test";
-
-describe("telemetry", () => {
- it.todo("initTelemetry is a no-op when OTEL_EXPORTER_OTLP_ENDPOINT is not set");
- it.todo("initTelemetry creates an SDK instance when endpoint is configured");
-});
diff --git a/src/telemetry.ts b/src/telemetry.ts
index 10cf562..43c72ef 100644
--- a/src/telemetry.ts
+++ b/src/telemetry.ts
@@ -71,6 +71,11 @@ const _slots = {
export async function initTelemetry(opts: TelemetryOptions): Promise {
if (_initialized) return;
+ // When there is no endpoint configured and no test provider, telemetry
+ // has nowhere to send data — skip the dynamic SDK imports entirely.
+ const hasEndpoint = opts.exporterEndpoint || process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
+ if (!opts._testProvider && !hasEndpoint) return;
+
try {
// Test path — register a caller-supplied TracerProvider directly.
if (opts._testProvider) {
diff --git a/src/tools/__tests__/memory.test.ts b/src/tools/__tests__/memory.test.ts
deleted file mode 100644
index 1a2269e..0000000
--- a/src/tools/__tests__/memory.test.ts
+++ /dev/null
@@ -1,7 +0,0 @@
-import { describe, it } from "node:test";
-
-describe("memory tool", () => {
- it.todo("load returns stored memory content");
- it.todo("save writes content and commits to git");
- it.todo("save requires content and message");
-});
diff --git a/test/memory.test.ts b/test/memory.test.ts
new file mode 100644
index 0000000..76ce582
--- /dev/null
+++ b/test/memory.test.ts
@@ -0,0 +1,181 @@
+/**
+ * Tests for the memory tool (src/tools/memory.ts).
+ *
+ * The memory tool provides git-backed persistent memory with load/save
+ * operations. Each save creates a git commit, giving full history of
+ * what the agent has remembered.
+ */
+import { describe, it, before } from "node:test";
+import assert from "node:assert/strict";
+import { mkdtemp, rm, writeFile, mkdir } from "fs/promises";
+import { join } from "path";
+import { tmpdir } from "os";
+import { execSync } from "child_process";
+
+let createMemoryTool: typeof import("../src/tools/memory.ts").createMemoryTool;
+
+before(async () => {
+ const mod = await import("../src/tools/memory.ts");
+ createMemoryTool = mod.createMemoryTool;
+});
+
+describe("memory tool", () => {
+ async function setupRepo(): Promise {
+ const dir = await mkdtemp(join(tmpdir(), "gitagent-memory-test-"));
+ execSync("git init -q", { cwd: dir });
+ execSync('git config --local user.email "test@gitagent.test"', { cwd: dir });
+ execSync('git config --local user.name "Test Agent"', { cwd: dir });
+ return dir;
+ }
+
+ async function cleanup(dir: string): Promise {
+ await rm(dir, { recursive: true, force: true }).catch(() => {});
+ }
+
+ describe("load", () => {
+ it("returns stored memory content", async () => {
+ const dir = await setupRepo();
+ try {
+ const tool = createMemoryTool(dir);
+
+ await tool.execute("call-1", {
+ action: "save",
+ content: "# Memory\n\n- Remember to buy milk\n- Project uses TypeScript",
+ message: "Initial memory",
+ });
+
+ const result = await tool.execute("call-2", { action: "load" });
+
+ assert.ok(result.content);
+ assert.equal(result.content.length, 1);
+ assert.ok(result.content[0].text.includes("Remember to buy milk"));
+ assert.ok(result.content[0].text.includes("Project uses TypeScript"));
+ } finally {
+ await cleanup(dir);
+ }
+ });
+
+ it("returns 'No memories yet.' when memory file is empty or missing", async () => {
+ const dir = await setupRepo();
+ try {
+ const tool = createMemoryTool(dir);
+
+ const result = await tool.execute("call-1", { action: "load" });
+
+ assert.equal(result.content[0].text, "No memories yet.");
+ } finally {
+ await cleanup(dir);
+ }
+ });
+
+ it("returns 'No memories yet.' when memory file has only heading", async () => {
+ const dir = await setupRepo();
+ try {
+ await mkdir(join(dir, "memory"), { recursive: true });
+ await writeFile(join(dir, "memory", "MEMORY.md"), "# Memory", "utf-8");
+
+ const tool = createMemoryTool(dir);
+ const result = await tool.execute("call-1", { action: "load" });
+
+ assert.equal(result.content[0].text, "No memories yet.");
+ } finally {
+ await cleanup(dir);
+ }
+ });
+ });
+
+ describe("save", () => {
+ it("writes content and commits to git", async () => {
+ const dir = await setupRepo();
+ try {
+ const tool = createMemoryTool(dir);
+
+ const result = await tool.execute("call-1", {
+ action: "save",
+ content: "# Memory\n\nSaved entry one.",
+ message: "First save",
+ });
+
+ assert.equal(result.content.length, 1);
+ assert.ok(
+ result.content[0].text.includes("Memory saved and committed"),
+ );
+ assert.ok(result.content[0].text.includes("First save"));
+
+ const { readFile } = await import("fs/promises");
+ const fileContent = await readFile(
+ join(dir, "memory", "MEMORY.md"),
+ "utf-8",
+ );
+ assert.ok(fileContent.includes("Saved entry one"));
+
+ const log = execSync("git log --oneline", {
+ cwd: dir,
+ encoding: "utf-8",
+ });
+ assert.ok(log.includes("First save"), `git log should contain commit: ${log}`);
+ } finally {
+ await cleanup(dir);
+ }
+ });
+
+ it("uses default commit message when message is omitted", async () => {
+ const dir = await setupRepo();
+ try {
+ const tool = createMemoryTool(dir);
+
+ await tool.execute("call-1", {
+ action: "save",
+ content: "Memory without explicit message.",
+ });
+
+ const log = execSync("git log --oneline", {
+ cwd: dir,
+ encoding: "utf-8",
+ });
+ assert.ok(
+ log.includes("Update memory"),
+ `commit should default to "Update memory": ${log}`,
+ );
+ } finally {
+ await cleanup(dir);
+ }
+ });
+
+ it("requires content for save action", async () => {
+ const dir = await setupRepo();
+ try {
+ const tool = createMemoryTool(dir);
+
+ await assert.rejects(
+ () =>
+ tool.execute("call-1", {
+ action: "save",
+ }),
+ /content is required for save action/,
+ );
+ } finally {
+ await cleanup(dir);
+ }
+ });
+ });
+
+ describe("abort signal", () => {
+ it("throws when signal is already aborted", async () => {
+ const dir = await setupRepo();
+ try {
+ const tool = createMemoryTool(dir);
+ const controller = new AbortController();
+ controller.abort();
+
+ await assert.rejects(
+ () =>
+ tool.execute("call-1", { action: "load" }, controller.signal),
+ /Operation aborted/,
+ );
+ } finally {
+ await cleanup(dir);
+ }
+ });
+ });
+});
diff --git a/test/telemetry-init.test.ts b/test/telemetry-init.test.ts
new file mode 100644
index 0000000..6d09184
--- /dev/null
+++ b/test/telemetry-init.test.ts
@@ -0,0 +1,121 @@
+/**
+ * Tests for the telemetry module (src/telemetry.ts) — init/shutdown/idempotency.
+ *
+ * These tests verify that initTelemetry correctly gates on the OTLP
+ * endpoint environment variable: it MUST return without enabling telemetry
+ * when no endpoint is configured, and it MUST successfully create an SDK
+ * instance when an endpoint (or test provider) is provided.
+ */
+import { describe, it, before, afterEach } from "node:test";
+import assert from "node:assert/strict";
+import { trace } from "@opentelemetry/api";
+import {
+ NodeTracerProvider,
+ InMemorySpanExporter,
+ SimpleSpanProcessor,
+} from "@opentelemetry/sdk-trace-node";
+
+let initTelemetry: typeof import("../src/telemetry.ts").initTelemetry;
+let shutdownTelemetry: typeof import("../src/telemetry.ts").shutdownTelemetry;
+let isTelemetryEnabled: typeof import("../src/telemetry.ts").isTelemetryEnabled;
+
+before(async () => {
+ const mod = await import("../src/telemetry.ts");
+ initTelemetry = mod.initTelemetry;
+ shutdownTelemetry = mod.shutdownTelemetry;
+ isTelemetryEnabled = mod.isTelemetryEnabled;
+});
+
+afterEach(async () => {
+ await shutdownTelemetry();
+ try {
+ trace.disable();
+ } catch {
+ /* ignore */
+ }
+});
+
+describe("telemetry init", () => {
+ function makeTestProvider() {
+ const exporter = new InMemorySpanExporter();
+ const provider = new NodeTracerProvider({
+ spanProcessors: [new SimpleSpanProcessor(exporter)],
+ });
+ return { exporter, provider };
+ }
+
+ it("returns without enabling telemetry when no OTLP endpoint is configured", async () => {
+ const saved = process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
+ const wasSet = "OTEL_EXPORTER_OTLP_ENDPOINT" in process.env;
+ delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
+
+ try {
+ await assert.doesNotReject(
+ () => initTelemetry({}),
+ "initTelemetry must never throw, even without an endpoint",
+ );
+
+ assert.equal(
+ isTelemetryEnabled(),
+ false,
+ "telemetry must remain disabled when no endpoint is configured",
+ );
+ } finally {
+ if (wasSet) {
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT = saved;
+ } else {
+ delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
+ }
+ }
+ });
+
+ it("creates an SDK instance when endpoint is configured", async () => {
+ process.env.OTEL_EXPORTER_OTLP_ENDPOINT = "http://localhost:4318";
+ const { exporter, provider } = makeTestProvider();
+
+ try {
+ await initTelemetry({
+ serviceName: "test-svc",
+ _testProvider: provider,
+ });
+
+ assert.equal(
+ isTelemetryEnabled(),
+ true,
+ "telemetry must be enabled after initTelemetry with _testProvider",
+ );
+
+ const tracer = trace.getTracer("test");
+ const span = tracer.startSpan("test-span");
+ span.end();
+
+ await provider.forceFlush();
+ const spans = exporter.getFinishedSpans();
+ assert.equal(spans.length, 1, "span should be exported");
+ assert.equal(spans[0].name, "test-span");
+ } finally {
+ delete process.env.OTEL_EXPORTER_OTLP_ENDPOINT;
+ }
+ });
+
+ it("is idempotent", async () => {
+ const { provider: provider1 } = makeTestProvider();
+ const { provider: provider2 } = makeTestProvider();
+
+ await initTelemetry({ _testProvider: provider1 });
+ assert.equal(isTelemetryEnabled(), true);
+
+ await initTelemetry({ _testProvider: provider2 });
+ assert.equal(isTelemetryEnabled(), true);
+ });
+
+ it("shutdownTelemetry resets the initialized state", async () => {
+ const { provider } = makeTestProvider();
+
+ await initTelemetry({ _testProvider: provider });
+ assert.equal(isTelemetryEnabled(), true);
+
+ await shutdownTelemetry();
+ assert.equal(isTelemetryEnabled(), false);
+ });
+});