Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -170,7 +170,7 @@ Node.js-based AI harness application using LangGraph for state machines and Open
└── schedules/ # Scheduled job output files
```

Misc details: The `config.yaml` file is the single source of project configuration, loaded by `src/config/loader.js`. All subsystems wire into the entry point `index.js`.
Misc details: The `config.yaml` file is the single source of project configuration, loaded by `src/config/loader.js`. All subsystems wire into the entry point `index.js` at the project root.

### 2.1 Quick Commands

Expand Down
2 changes: 1 addition & 1 deletion CONTRIBUTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,7 +105,7 @@ chore: pin dependencies in package.json

### Architecture Notes

- `config.yaml` + `src/config/loader.js` is the single source of truth for configuration
- `config.yaml` at the project root + `src/config/loader.js` is the single source of truth for configuration
- All subsystems wire into `index.js` via `src/` modules
- Each public function or class needs test coverage
- Test files mirror source: `src/memory/reader.js``tests/unit/memory.test.js`
Expand Down
6 changes: 3 additions & 3 deletions Dockerfile
Original file line number Diff line number Diff line change
Expand Up @@ -35,15 +35,15 @@ WORKDIR /app
COPY --from=builder /app/node_modules ./node_modules
COPY --from=builder /app/package*.json ./
COPY LICENSE ./
COPY index.js ./
COPY src/ ./src/
COPY config.yaml ./
COPY prompts/ ./prompts/
COPY system-skills/ ./system-skills/
COPY docker-entrypoint.sh /docker-entrypoint.sh
RUN chmod +x /docker-entrypoint.sh

RUN mkdir -p /home/madz/.cache/madz/logs && \
touch /home/madz/.cache/madz/logs/madz_cron.log && \
chown -R madz:node /app /home/madz && \
RUN chown -R madz:node /app /home/madz && \
chmod -R g+rwX /app /home/madz

ENTRYPOINT ["/docker-entrypoint.sh"]
Expand Down
File renamed without changes.
77 changes: 24 additions & 53 deletions src/index.js → index.js
Original file line number Diff line number Diff line change
Expand Up @@ -18,26 +18,26 @@ const parsed = yargs(process.argv.slice(2))
}).argv;

// Load config
import { loadConfig } from "./config/loader.js";
import { loadConfig } from "./src/config/loader.js";
const config = loadConfig();
import { fileURLToPath } from "node:url";
import { loadSession } from "./session/loader.js";
import { loadSession } from "./src/session/loader.js";

import React from "react";

const { setConfigValue } = await import("./config/loader.js");
const { createDeepAgentsOrchestrator } = await import("./agent/deepAgents.js");
const { logger } = await import("./logger.js");
const { setConfigValue } = await import("./src/config/loader.js");
const { createDeepAgentsOrchestrator } = await import("./src/agent/deepAgents.js");
const { logger } = await import("./src/logger.js");

const { default: pkg } = await import(new URL("../package.json", import.meta.url).href, {
const { default: pkg } = await import(new URL("./package.json", import.meta.url).href, {
with: { type: "json" },
});

// Initialize subsystems
// Sync crontab from persisted job definitions (runs before any subsystem)
if (config.schedules.syncOnInit !== false) {
try {
const { Cron } = await import("./scheduler/cron.js");
const { Cron } = await import("./src/scheduler/cron.js");
if (config.schedules.logPath) {
Cron.setLogPath(config.schedules.logPath);
}
Expand All @@ -50,53 +50,22 @@ if (config.schedules.syncOnInit !== false) {
`[scheduler] Crontab sync complete: +${result.added} added, -${result.removed} removed, ~${result.updated} updated, =${result.skipped} skipped`,
);
}

// Ensure the daily reflection job exists in crontab and persisted (covers upgrading users
// who have no reflection-daily.json on disk). Cron.add() is idempotent.
const cwd = config.cwd;
const jobResult = Cron.add({
name: "reflection-daily",
cron: "0 2 * * *",
command: `cd ${cwd} && node index.js --message "Run the reflection skill"`,
});
if (jobResult.added || !jobResult.error) {
try {
const { existsSync, mkdirSync, writeFileSync } = await import("node:fs");
const { join } = await import("node:path");
const schedulesDir = config.memory?.schedulesDir || "memory/schedules/";
const filePath = join(schedulesDir, "reflection-daily.json");
if (!existsSync(filePath)) {
mkdirSync(schedulesDir, { recursive: true });
const jobData = {
name: "reflection-daily",
cron: "0 2 * * *",
command: `cd ${cwd} && node index.js --message "Run the reflection skill"`,
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
writeFileSync(filePath, JSON.stringify(jobData, null, 2));
}
} catch (err) {
logger.warn(`[scheduler] Failed to persist reflection-daily job file: ${err.message}`);
}
}
} catch (err) {
logger.warn(`[scheduler] Crontab sync error: ${err.message}`);
}
}

// Ensure sessions directory exists before any subsystem initialization
const { ensureSessionsDir } = await import("./session/index.js");
const { ensureSessionsDir } = await import("./src/session/index.js");
await ensureSessionsDir(config.cwd + "/" + "memory/sessions/");

// Initialize contextual onboarding if profile is missing (with graceful degradation)
let onboardingInstance = null;
try {
const { hasProfile, ATTRIBUTES } = await import("./memory/profile.js");
const { hasProfile, ATTRIBUTES } = await import("./src/memory/profile.js");
if (!hasProfile()) {
const { createOnboarding } = await import("./session/onboarding.js");
const { setupAutoSchedule } = await import("./scheduler/autoSchedule.js");
const { createOnboarding } = await import("./src/session/onboarding.js");
const { setupAutoSchedule } = await import("./src/scheduler/autoSchedule.js");
const autoSchedule = setupAutoSchedule();
onboardingInstance = createOnboarding(ATTRIBUTES, { onSave: autoSchedule });
}
Expand All @@ -108,27 +77,29 @@ try {
let tracer = null;
let shutdownFn = null;
if (config.telemetry.enabled) {
const { initTelemetry, getTracer, shutdownTelemetry } = await import("./telemetry/provider.js");
const { initTelemetry, getTracer, shutdownTelemetry } =
await import("./src/telemetry/provider.js");
await initTelemetry(config.telemetry);
tracer = getTracer();
shutdownFn = shutdownTelemetry;
}

// Initialize skill registry
const { SkillRegistry, resolvePermissions, ensureSkillsDir } = await import("./skills/index.js");
const { SkillRegistry, resolvePermissions, ensureSkillsDir } =
await import("./src/skills/index.js");
const registry = new SkillRegistry();
await ensureSkillsDir(config.cwd + "/" + "skills/");
registry.discover();

// Initialize memory system
const { writeMemoryFile, readMemoryFile, loadContext } = await import("./memory/index.js");
const { writeMemoryFile, readMemoryFile, loadContext } = await import("./src/memory/index.js");

// Initialize GC manager (if enabled)
let gcManager = null;
let gcTrace = null;
let maxGcPerHour = 4;
try {
const { initGC, gc: gcFn, isAvailable } = await import("./memory/gc.js");
const { initGC, gc: gcFn, isAvailable } = await import("./src/memory/gc.js");
const gcConfig = config.memory?.gc;
if (gcConfig?.enabled !== false) {
const idleTimeoutMs = gcConfig.idleTimeoutMs ?? 300000;
Expand All @@ -152,11 +123,11 @@ try {

// Initialize session
const { createSession, SessionStateManager, saveSession, handleShutdown, registerShutdownHandler } =
await import("./session/index.js");
const { flush: flushLogger } = await import("./logger.js");
await import("./src/session/index.js");
const { flush: flushLogger } = await import("./src/logger.js");

// Initialize scheduler
const { ScheduleManager } = await import("./scheduler/index.js");
const { ScheduleManager } = await import("./src/scheduler/index.js");
const scheduleManager = new ScheduleManager();

// Create or restore session
Expand All @@ -168,7 +139,7 @@ const sessionState = new SessionStateManager(initialState);

// Session-init: asynchronously clean up expired ephemeral memories (non-blocking)
try {
const { expireEphemeralMemories } = await import("./memory/expireEphemeral.js");
const { expireEphemeralMemories } = await import("./src/memory/expireEphemeral.js");
queueMicrotask(() =>
expireEphemeralMemories(config.cwd + "/" + config.memory.contextDir).catch(() => {}),
);
Expand All @@ -177,7 +148,7 @@ try {
}

// Create checkpointer before tools so compactContext can access it
const { createCheckpointer } = await import("./session/checkpointer.js");
const { createCheckpointer } = await import("./src/session/checkpointer.js");
const checkpointer = createCheckpointer(config.persistence);

// Provider config for TUI
Expand Down Expand Up @@ -331,7 +302,7 @@ if (isMain) {
process.exit(0);
} else {
const { render } = await import("ink");
const App = (await import("./tui/app.js")).default;
const App = (await import("./src/tui/app.js")).default;
const appInfo = { name: config.tui.name, version: pkg.version };
render(
React.createElement(App, {
Expand All @@ -356,7 +327,7 @@ if (isMain) {
{
// Restore terminal with newline when app exits
onExit: async () => {
const shutdown = (await import("./session/index.js")).handleShutdown;
const shutdown = (await import("./src/session/index.js")).handleShutdown;
if (shutdown) await shutdown();
await flushLogger();
process.stdout.write("\n");
Expand Down
10 changes: 5 additions & 5 deletions package.json
Original file line number Diff line number Diff line change
Expand Up @@ -33,14 +33,14 @@
],
"author": "Jason Mulligan <jason.mulligan@avoidwork.com>",
"type": "module",
"main": "src/index.js",
"bin": "src/index.js",
"main": "index.js",
"bin": "index.js",
"scripts": {
"start": "node src/index.js --mode interactive",
"start": "node index.js --mode interactive",
"test": "node --test tests/**/*.test.js",
"coverage": "node --test --experimental-test-coverage --test-coverage-exclude=dist/** --test-coverage-exclude=tests/** --test-reporter=spec tests/**/*.test.js 2>&1 | grep -A 1000 \"start of coverage report\" > coverage.txt",
"fix": "oxlint --fix *.js src tests/unit && oxfmt *.js src tests/unit --write",
"lint": "oxlint *.js src tests/unit && oxfmt *.js src/*.js tests/unit/*.js --check",
"fix": "oxlint --fix *.js src tests/unit tests/integration tests/tui && oxfmt *.js src tests/unit tests/integration tests/tui --write",
"lint": "oxlint *.js src tests/unit tests/integration tests/tui && oxfmt *.js src tests/unit tests/integration tests/tui --check",
"docker:build": "docker build -t madz:latest .",
"docker:tag": "docker tag madz:latest $DOCKER_USER/madz:$npm_package_version",
"docker:push": "docker push $DOCKER_USER/madz:$npm_package_version",
Expand Down
2 changes: 1 addition & 1 deletion src/config/loader.js
Original file line number Diff line number Diff line change
Expand Up @@ -9,7 +9,7 @@ const _require = createRequire(import.meta.url);
import { load, dump } from "js-yaml";

const PROJECT_ROOT = dirname(fileURLToPath(import.meta.url));
const CONFIG_PATH = join(PROJECT_ROOT, "../config.yaml");
const CONFIG_PATH = join(PROJECT_ROOT, "../../config.yaml");

/// -- Convert camelCase or kebab-case to SNAKE_CASE ---

Expand Down
15 changes: 11 additions & 4 deletions src/scheduler/autoSchedule.js
Original file line number Diff line number Diff line change
@@ -1,4 +1,4 @@
import { existsSync, mkdirSync, writeFileSync } from "node:fs";
import { existsSync, mkdirSync, readFileSync, writeFileSync } from "node:fs";
import { join } from "node:path";
import { Cron } from "./cron.js";
import { logger } from "../logger.js";
Expand Down Expand Up @@ -28,7 +28,7 @@ function createJobDefinition(cwd) {
return {
name: "reflection-daily",
cron: JOB_CRON,
command: `cd ${cwd} && timeout 300 node src/index.js --message "Run the reflection skill"`,
command: `cd ${cwd} && node index.js --message "Run the reflection skill"`,
};
}

Expand All @@ -45,7 +45,14 @@ function persistJobFile(jobName, job, cwd) {
const filePath = join(schedulesDir, `${jobName}.json`);

if (existsSync(filePath)) {
return { written: true };
try {
const existing = JSON.parse(readFileSync(filePath, "utf8"));
if (existing.command === job.command) {
return { written: true };
}
} catch {
/* malformed JSON — fall through to overwrite */
}
}

try {
Expand All @@ -59,7 +66,7 @@ function persistJobFile(jobName, job, cwd) {
const jobData = Object.freeze({
name: job.name,
cron: job.cron,
command: `cd ${cwd} && timeout 300 node src/index.js --message "Run the reflection skill"`,
command: `cd ${cwd} && node index.js --message "Run the reflection skill"`,
enabled: true,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
Expand Down
2 changes: 1 addition & 1 deletion tests/integration/full-flow.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -193,7 +193,7 @@ describe("integration - scheduler execution", () => {
it("enforces concurrent execution limits", async () => {
const maxConcurrent = 1;
let concurrent = 0;
const scheduleTasks = [
const _scheduleTasks = [
{ name: "A", cron: "0 9 * * *", skill: "x" },
{ name: "B", cron: "0 9 * * *", skill: "y" },
];
Expand Down
4 changes: 2 additions & 2 deletions tests/unit/autoSchedule.test.js
Original file line number Diff line number Diff line change
Expand Up @@ -65,7 +65,7 @@ describe("setupAutoSchedule", () => {
assert.strictEqual(capturedJob.cron, "0 2 * * *");
assert.ok(typeof capturedJob.command === "string");
assert.ok(
capturedJob.command.includes('node src/index.js --message "Run the reflection skill"'),
capturedJob.command.includes('node index.js --message "Run the reflection skill"'),
"Command should include the --message /reflection command",
);
});
Expand Down Expand Up @@ -99,7 +99,7 @@ describe("setupAutoSchedule", () => {
assert.strictEqual(content.cron, "0 2 * * *");
assert.ok(
content.command.startsWith("cd ") &&
content.command.includes('node src/index.js --message "Run the reflection skill"'),
content.command.includes('node index.js --message "Run the reflection skill"'),
);
assert.strictEqual(content.enabled, true);
assert.ok(content.createdAt);
Expand Down