diff --git a/AGENTS.md b/AGENTS.md index 51902024..223f151e 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/CONTRIBUTING.md b/CONTRIBUTING.md index a5211f1d..a4a02690 100644 --- a/CONTRIBUTING.md +++ b/CONTRIBUTING.md @@ -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` diff --git a/Dockerfile b/Dockerfile index 59ee04bb..57b803f8 100644 --- a/Dockerfile +++ b/Dockerfile @@ -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"] diff --git a/src/config.yaml b/config.yaml similarity index 100% rename from src/config.yaml rename to config.yaml diff --git a/src/index.js b/index.js similarity index 76% rename from src/index.js rename to index.js index dcc34f3b..e0c06ccc 100644 --- a/src/index.js +++ b/index.js @@ -18,18 +18,18 @@ 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" }, }); @@ -37,7 +37,7 @@ const { default: pkg } = await import(new URL("../package.json", import.meta.url // 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); } @@ -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 }); } @@ -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; @@ -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 @@ -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(() => {}), ); @@ -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 @@ -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, { @@ -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"); diff --git a/package.json b/package.json index 952aa05d..c1ae5c78 100644 --- a/package.json +++ b/package.json @@ -33,14 +33,14 @@ ], "author": "Jason Mulligan ", "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", diff --git a/src/config/loader.js b/src/config/loader.js index c43165c7..6b9edf03 100644 --- a/src/config/loader.js +++ b/src/config/loader.js @@ -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 --- diff --git a/src/scheduler/autoSchedule.js b/src/scheduler/autoSchedule.js index b0b8f3a9..2a05f7cc 100644 --- a/src/scheduler/autoSchedule.js +++ b/src/scheduler/autoSchedule.js @@ -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"; @@ -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"`, }; } @@ -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 { @@ -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(), diff --git a/tests/integration/full-flow.test.js b/tests/integration/full-flow.test.js index 437814c1..bbfb70ae 100644 --- a/tests/integration/full-flow.test.js +++ b/tests/integration/full-flow.test.js @@ -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" }, ]; diff --git a/tests/unit/autoSchedule.test.js b/tests/unit/autoSchedule.test.js index eb6d91e5..6abcefec 100644 --- a/tests/unit/autoSchedule.test.js +++ b/tests/unit/autoSchedule.test.js @@ -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", ); }); @@ -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);