Skip to content
4 changes: 2 additions & 2 deletions Documentation.md
Original file line number Diff line number Diff line change
@@ -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)

---

Expand Down Expand Up @@ -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
```

---
Expand Down
6 changes: 3 additions & 3 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -3,7 +3,7 @@
</p>

<p align="center">
<img src="https://img.shields.io/npm/v/gitagent?style=flat-square&color=blue" alt="npm version" />
<img src="https://img.shields.io/npm/v/@open-gitagent/gitagent?style=flat-square&color=blue" alt="npm version" />
<img src="https://img.shields.io/badge/node-%3E%3D20-brightgreen?style=flat-square" alt="node version" />
<img src="https://img.shields.io/github/license/open-gitagent/gitagent?style=flat-square" alt="license" />
<img src="https://img.shields.io/badge/TypeScript-5.7-blue?style=flat-square&logo=typescript&logoColor=white" alt="typescript" />
Expand Down Expand Up @@ -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:

Expand Down Expand Up @@ -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:
Expand Down
4 changes: 2 additions & 2 deletions install.sh
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down
2 changes: 1 addition & 1 deletion package.json
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
6 changes: 0 additions & 6 deletions src/__tests__/telemetry.test.ts

This file was deleted.

5 changes: 5 additions & 0 deletions src/telemetry.ts
Original file line number Diff line number Diff line change
Expand Up @@ -71,6 +71,11 @@ const _slots = {
export async function initTelemetry(opts: TelemetryOptions): Promise<void> {
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) {
Expand Down
7 changes: 0 additions & 7 deletions src/tools/__tests__/memory.test.ts

This file was deleted.

181 changes: 181 additions & 0 deletions test/memory.test.ts
Original file line number Diff line number Diff line change
@@ -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<string> {
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<void> {
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);
}
});
});
});
121 changes: 121 additions & 0 deletions test/telemetry-init.test.ts
Original file line number Diff line number Diff line change
@@ -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);
});
});