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
3 changes: 3 additions & 0 deletions .env.example
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,9 @@ DEVICE_NAME=vault-cortex-lightsail
CONFLICT_STRATEGY=merge
SYNC_MODE=bidirectional
LOG_LEVEL=info
# Days to retain persistent log files before cleanup (default: 30).
# LOG_DIR is set in docker-compose.yml (/data/logs) β€” no need to set it here.
# LOG_RETENTION_DAYS=30
TZ=UTC

# AWS region β€” only used by SST deploys (sst.config.ts). Ignored by Docker containers.
Expand Down
2 changes: 1 addition & 1 deletion .github/workflows/deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -172,7 +172,7 @@ jobs:
IP: ${{ steps.ssh.outputs.host }}
run: |
ssh -o StrictHostKeyChecking=accept-new ubuntu@"$IP" \
'cd /opt/vault-cortex && docker compose pull && docker compose up -d --wait --wait-timeout 180'
'cd /opt/vault-cortex && docker compose pull && docker compose up -d --wait --wait-timeout 180 && docker image prune -f'

- name: Healthcheck
env:
Expand Down
6 changes: 6 additions & 0 deletions AGENTS.md
Original file line number Diff line number Diff line change
Expand Up @@ -145,6 +145,12 @@ search.fullTextSearch({ query, filters }, reqLogger)
- TypeScript strict mode. `node:` prefix for built-ins.
- Explicit return types on exports. Zod for MCP tool schemas.
- No `any`. Prefer `async/await` over `.then()`/`.catch()`.
- Luxon `DateTime` over the native `Date` API. Luxon is declarative
(`DateTime.now().minus({ days: 7 }).toISODate()`), immutable, and
avoids manual arithmetic (`Date.now() - 7 * 86_400_000`) and
mutation (`date.setDate()`). Use `DateTime.now()` for current time,
`.toISO()` for timestamps, `.toISODate()` for date-only strings,
`.toUnixInteger()` for epoch seconds.
- Immutable by default. Avoid `let` β€” carry state in reduce
accumulators, use early returns, or destructure conditional results.
A bit of duplication is acceptable to keep code immutable and clear.
Expand Down
2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,8 @@ All settings are environment variables with sensible defaults.
| `TZ` | β€” | `UTC` | IANA timezone for timestamps and daily note resolution |
| `SERVICE_DOCUMENTATION_URL` | β€” | GitHub repo URL | URL returned in OAuth discovery metadata |
| `LOG_LEVEL` | β€” | `info` | Logging verbosity: `debug`, `info`, `warn`, `error` |
| `LOG_DIR` | β€” | `/data/logs` (Docker) | Directory for persistent log files. Logs survive container restarts. |
| `LOG_RETENTION_DAYS` | β€” | `30` | Days to keep log files before automatic cleanup on startup |

**Smart defaults:** Setting `MEMORY_DIR` automatically updates the defaults for `PROTECTED_PATHS` and `ORPHAN_EXCLUDE_FOLDERS`. You only set those explicitly for a fully custom list.

Expand Down
3 changes: 3 additions & 0 deletions deploy/local/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -24,3 +24,6 @@ VAULT_PATH=

# Log verbosity: debug | info | warn | error (default: info).
# LOG_LEVEL=info

# Days to retain persistent log files before cleanup (default: 30).
# LOG_RETENTION_DAYS=30
2 changes: 2 additions & 0 deletions deploy/local/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,8 @@ services:
PUBLIC_URL: ${PUBLIC_URL:-http://localhost:8000}
MEMORY_DIR: ${MEMORY_DIR:-About Me}
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_DIR: /data/logs
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
TZ: ${TZ:-UTC}
# Uncomment to override smart defaults (see Configuration in the main README):
# PROTECTED_PATHS: ${PROTECTED_PATHS:-}
Expand Down
3 changes: 3 additions & 0 deletions deploy/remote/.env.example
Original file line number Diff line number Diff line change
Expand Up @@ -37,6 +37,9 @@ VAULT_NAME=
# Log verbosity: debug | info | warn | error (default: info).
# LOG_LEVEL=info

# Days to retain persistent log files before cleanup (default: 30).
# LOG_RETENTION_DAYS=30

# User/group IDs for obsidian-sync (default: 1000).
# PUID=1000
# PGID=1000
Expand Down
2 changes: 2 additions & 0 deletions deploy/remote/docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -69,6 +69,8 @@ services:
PUBLIC_URL: "${PUBLIC_URL:?Set PUBLIC_URL to your server's public URL}"
MEMORY_DIR: ${MEMORY_DIR:-About Me}
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_DIR: /data/logs
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
TZ: ${TZ:-UTC}
# Uncomment to override smart defaults (see Configuration in the main README):
# PROTECTED_PATHS: ${PROTECTED_PATHS:-}
Expand Down
2 changes: 2 additions & 0 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -70,6 +70,8 @@ services:
MCP_AUTH_TOKEN: ${MCP_AUTH_TOKEN:?MCP_AUTH_TOKEN required for in-process auth}
PUBLIC_URL: ${PUBLIC_URL:?PUBLIC_URL required for OAuth metadata}
LOG_LEVEL: ${LOG_LEVEL:-info}
LOG_DIR: /data/logs
LOG_RETENTION_DAYS: ${LOG_RETENTION_DAYS:-30}
TZ: ${TZ:-UTC}
MEMORY_DIR: ${MEMORY_DIR:-About Me}
PROTECTED_PATHS: ${PROTECTED_PATHS:-}
Expand Down
2 changes: 1 addition & 1 deletion scripts/dev.ts
Original file line number Diff line number Diff line change
Expand Up @@ -161,7 +161,7 @@ switch (sub) {
)
run(`scp ${id} ${sshOpts} ${ENV_PATH} ubuntu@${ip}:/opt/vault-cortex/.env`)
run(
`ssh ${id} ${sshOpts} ubuntu@${ip} 'cd /opt/vault-cortex && docker compose pull && docker compose up -d'`,
`ssh ${id} ${sshOpts} ubuntu@${ip} 'cd /opt/vault-cortex && docker compose pull && docker compose up -d && docker image prune -f'`,
)
console.log(`βœ“ vault-mcp deployed to ${ip}:8000`)
break
Expand Down
181 changes: 181 additions & 0 deletions src/__tests__/logger.test.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,181 @@
import { describe, it, expect, onTestFinished } from "vitest"
import {
mkdtempSync,
rmSync,
readFileSync,
writeFileSync,
readdirSync,
existsSync,
} from "node:fs"
import { join } from "node:path"
import { tmpdir } from "node:os"
import { DateTime, Settings } from "luxon"
import { createFileSinkExtension, pruneOldLogFiles } from "../logger.js"

const createTempDir = (): string => {
const dir = mkdtempSync(join(tmpdir(), "logger-test-"))
onTestFinished(() => {
try {
rmSync(dir, { recursive: true, force: true })
} catch {
// ignore β€” OS tmp dir handles stragglers
}
})
return dir
}

const sampleLine = (message: string): string =>
JSON.stringify({
timestamp: DateTime.now().toISO(),
level: "info",
name: "test",
message,
}) + "\n"

const sampleEntry = (message: string) => ({
timestamp: DateTime.now().toISO(),
level: "info" as const,
name: "test",
message,
data: {},
})

describe("createFileSinkExtension", () => {
it("writes log lines to a date-stamped file", () => {
const logDir = createTempDir()
const extension = createFileSinkExtension(logDir)

extension(sampleEntry("hello"), sampleLine("hello"))
extension(sampleEntry("world"), sampleLine("world"))

const today = DateTime.now().toISODate()
const content = readFileSync(join(logDir, `vault-mcp-${today}.log`), "utf8")
expect(content).toContain('"message":"hello"')
expect(content).toContain('"message":"world"')
})

it("creates the log directory if it does not exist", () => {
const parentDir = createTempDir()
const nestedLogDir = join(parentDir, "nested", "logs")

createFileSinkExtension(nestedLogDir)

expect(existsSync(nestedLogDir)).toBe(true)
})

it("appends to an existing log file", () => {
const logDir = createTempDir()
const today = DateTime.now().toISODate()
const logFile = join(logDir, `vault-mcp-${today}.log`)

writeFileSync(logFile, '{"message":"existing"}\n')

const extension = createFileSinkExtension(logDir)
extension(sampleEntry("appended"), sampleLine("appended"))

const content = readFileSync(logFile, "utf8")
expect(content).toContain('"message":"existing"')
expect(content).toContain('"message":"appended"')
})

it("rolls to a new file when the date changes", () => {
const logDir = createTempDir()
onTestFinished(() => {
Settings.now = () => Date.now()
})

// Day 1
Settings.now = () => Date.parse("2026-01-15T12:00:00Z")
const extension = createFileSinkExtension(logDir, 30)
extension(sampleEntry("day1"), '{"message":"day1"}\n')

// Day 2
Settings.now = () => Date.parse("2026-01-16T12:00:00Z")
extension(sampleEntry("day2"), '{"message":"day2"}\n')

const files = readdirSync(logDir)
.filter((filename) => filename.endsWith(".log"))
.sort()
expect(files).toEqual([
"vault-mcp-2026-01-15.log",
"vault-mcp-2026-01-16.log",
])

const day1Content = readFileSync(
join(logDir, "vault-mcp-2026-01-15.log"),
"utf8",
)
const day2Content = readFileSync(
join(logDir, "vault-mcp-2026-01-16.log"),
"utf8",
)
expect(day1Content).toContain('"message":"day1"')
expect(day2Content).toContain('"message":"day2"')
})

it("prunes old files on creation", () => {
const logDir = createTempDir()
writeFileSync(join(logDir, "vault-mcp-2020-01-01.log"), "ancient")

createFileSinkExtension(logDir, 7)

const remaining = readdirSync(logDir)
expect(remaining).toHaveLength(0)
})
})

describe("pruneOldLogFiles", () => {
it("deletes log files older than retention period", () => {
const logDir = createTempDir()

writeFileSync(join(logDir, "vault-mcp-2020-01-01.log"), "old")
writeFileSync(join(logDir, "vault-mcp-2020-06-15.log"), "also old")
const today = DateTime.now().toISODate()
writeFileSync(join(logDir, `vault-mcp-${today}.log`), "current")

pruneOldLogFiles(logDir, 30)

const remaining = readdirSync(logDir)
expect(remaining).toHaveLength(1)
expect(remaining[0]).toBe(`vault-mcp-${today}.log`)
})

it("ignores non-matching files", () => {
const logDir = createTempDir()

writeFileSync(join(logDir, "vault-mcp-2020-01-01.log"), "old")
writeFileSync(join(logDir, "other-file.txt"), "keep me")
writeFileSync(join(logDir, "README.md"), "keep me too")

pruneOldLogFiles(logDir, 30)

const remaining = readdirSync(logDir).sort()
expect(remaining).toEqual(["README.md", "other-file.txt"])
})

it("keeps files within retention window", () => {
const logDir = createTempDir()

const today = DateTime.now().toISODate()
const yesterday = DateTime.now().minus({ days: 1 }).toISODate()
writeFileSync(join(logDir, `vault-mcp-${today}.log`), "today")
writeFileSync(join(logDir, `vault-mcp-${yesterday}.log`), "yesterday")

pruneOldLogFiles(logDir, 30)

const remaining = readdirSync(logDir)
expect(remaining).toHaveLength(2)
})

it("respects custom retention days", () => {
const logDir = createTempDir()

const twoDaysAgo = DateTime.now().minus({ days: 2 }).toISODate()
writeFileSync(join(logDir, `vault-mcp-${twoDaysAgo}.log`), "old-ish")

pruneOldLogFiles(logDir, 1)

const remaining = readdirSync(logDir)
expect(remaining).toHaveLength(0)
})
})
81 changes: 76 additions & 5 deletions src/logger.ts
Original file line number Diff line number Diff line change
@@ -1,4 +1,7 @@
import { env } from "node:process"
import { mkdirSync, readdirSync, unlinkSync, appendFileSync } from "node:fs"
import { join } from "node:path"
import { DateTime } from "luxon"

type LogLevel = "debug" | "info" | "warn" | "error"

Expand All @@ -10,7 +13,7 @@ type LogEntry = {
data: Record<string, unknown>
}

type LogExtension = (entry: LogEntry) => void
type LogExtension = (entry: LogEntry, line: string) => void

export type Logger = {
debug: (message: string, data?: Record<string, unknown>) => void
Expand Down Expand Up @@ -43,6 +46,72 @@ const getCallerSource = (): string => {
return `${file}:${frame.getLineNumber()}`
}

// ── File sink extension ─────────────────────────────────────

const LOG_FILE_PREFIX = "vault-mcp-"
const LOG_FILE_SUFFIX = ".log"
/** Matches date-stamped log files: vault-mcp-YYYY-MM-DD.log */
const LOG_FILE_PATTERN = /^vault-mcp-(\d{4}-\d{2}-\d{2})\.log$/

const DEFAULT_RETENTION_DAYS = 30

const todayDateString = (): string => DateTime.now().toISODate()

/** Deletes log files older than retentionDays. */
export const pruneOldLogFiles = (
logDir: string,
retentionDays: number,
): void => {
const cutoffDate = DateTime.now().minus({ days: retentionDays }).toISODate()

for (const filename of readdirSync(logDir)) {
const logFileMatch = LOG_FILE_PATTERN.exec(filename)
const [, fileDate] = logFileMatch ?? []
if (fileDate && fileDate < cutoffDate) {
unlinkSync(join(logDir, filename))
}
}
}

/** Creates a LogExtension that appends each line to a date-stamped file.
* Rolls to a new file at midnight. Prunes files older than retentionDays on creation. */
export const createFileSinkExtension = (
logDir: string,
retentionDays: number = DEFAULT_RETENTION_DAYS,
): LogExtension => {
mkdirSync(logDir, { recursive: true })
pruneOldLogFiles(logDir, retentionDays)

const logPath = (): string =>
join(logDir, `${LOG_FILE_PREFIX}${todayDateString()}${LOG_FILE_SUFFIX}`)

// `line` is the same JSON string already written to stdout/stderr by emit()
return (_entry: LogEntry, line: string): void => {
appendFileSync(logPath(), line)
}
}

// ── Logger ──────────────────────────────────────────────────

const parseRetentionDays = (
envValue: string | undefined,
): number | undefined => {
if (!envValue) return undefined
const retentionDays = parseInt(envValue, 10)
return Number.isNaN(retentionDays) ? undefined : retentionDays
}

const fileSinkExtension: LogExtension | undefined = env.LOG_DIR
? createFileSinkExtension(
env.LOG_DIR,
parseRetentionDays(env.LOG_RETENTION_DAYS),
)
: undefined

const defaultExtensions: LogExtension[] = fileSinkExtension
? [fileSinkExtension]
: []

const createLogger = (
name: string,
options?: {
Expand All @@ -65,7 +134,7 @@ const createLogger = (

const mergedData = { ...baseProps, ...data }
const entry: LogEntry = {
timestamp: new Date().toISOString(),
timestamp: DateTime.now().toISO(),
level,
name,
message,
Expand All @@ -84,8 +153,8 @@ const createLogger = (
if (level === "error") process.stderr.write(line)
else process.stdout.write(line)

for (const ext of extensions) {
ext(entry)
for (const extension of extensions) {
extension(entry, line)
}
}

Expand All @@ -102,4 +171,6 @@ const createLogger = (
}
}

export const logger = createLogger("vault-cortex")
export const logger = createLogger("vault-cortex", {
extensions: defaultExtensions,
})