Skip to content
Open
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
18 changes: 18 additions & 0 deletions .editorconfig
Original file line number Diff line number Diff line change
@@ -0,0 +1,18 @@
root = true
[*]

# Change these settings to your own preference
indent_style = tab
indent_size = 1

# We recommend you to keep these unchanged
end_of_line = lf
charset = utf-8
trim_trailing_whitespace = true
insert_final_newline = true

[*.md]
trim_trailing_whitespace = false

[*.ts]
trim_trailing_whitespace = false
1 change: 1 addition & 0 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@ import { Container, Markdown, Spacer, Text } from "@mariozechner/pi-tui";
import { Type } from "@sinclair/typebox";
import { StringEnum, completeSimple, getModel } from "@mariozechner/pi-ai";
import * as fs from "node:fs";
import * as os from "node:os";
import * as path from "node:path";
import { execFileSync } from "node:child_process";

Expand Down
34 changes: 26 additions & 8 deletions lib.ts
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@ export function resolveHomeDir(
env: Record<string, string | undefined> = process.env,
fallback = os.homedir(),
): string {
fallback ??= os.homedir();
return env.HOME
?? env.USERPROFILE
?? (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined)
Expand All @@ -66,13 +67,15 @@ export function resolveAgentDir(
env: Record<string, string | undefined> = process.env,
fallbackHome = os.homedir(),
): string {
fallbackHome ??= os.homedir();
return env.PI_CODING_AGENT_DIR ?? path.join(resolveHomeDir(env, fallbackHome), ".pi", "agent");
}

export function resolveSessionsDir(
env: Record<string, string | undefined> = process.env,
fallbackHome = os.homedir(),
): string {
fallbackHome ??= os.homedir();
return path.join(resolveAgentDir(env, fallbackHome), "sessions");
}

Expand Down Expand Up @@ -169,9 +172,24 @@ export function dailyPath(dailyDir: string, date: string): string {

/** Validate and normalize a relative file path within the memory directory. Returns null if path escapes memoryDir. */
export function safeResolvePath(memoryDir: string, filename: string): { resolved: string; normalized: string } | null {
const normalized = path.normalize(filename).replace(/^\/+/, "");
if (normalized.startsWith("..") || path.isAbsolute(filename)) return null;
return { resolved: path.join(memoryDir, normalized), normalized };
memoryDir = path.normalize(memoryDir);
filename = path.normalize(filename);

// Block absolute paths
if (/^(\\|\/)/.test(filename) || (os.platform() === 'win32' && /^\w:(\/|\\)/.test(filename))) return null;

// Join and resolve memoryDir and filename to get the full path to the file.
const resolved = path.join(memoryDir, filename);


// Block directory traversal: Return null when joining the normalized memoryDir and filename produces a path that
// does not start with memoryDir.
if (!resolved.startsWith(memoryDir)) return null;

// Remove memoryDir from the resolved path and return it as normalized. Trim any leading slashes.
const normalized = resolved.replace(memoryDir, '').replace(/^(\\|\/)/, '');

return { resolved, normalized };
}

export interface IndexEntry {
Expand Down Expand Up @@ -263,13 +281,13 @@ export function resolveIndexedFile(config: MemoryConfig, directory: string, quer
const indexContent = readFileSafe(indexPath);
if (!indexContent) {
const alternatives = listSiblingIndexedDirectories(config, directory);
const suffix = alternatives.length > 0 ? ` Indexed directories nearby: ${alternatives.map(d => `${d}/`).join(", ")}` : "";
const suffix = alternatives.length > 0 ? ` Indexed directories nearby: ${alternatives.map(d => `${d}${path.sep}`).join(", ")}` : "";
return { text: `No INDEX.md for ${directory}.${suffix}`, details: { directory, found: false, reason: "missing_index" } };
}

const entries = parseIndexFile(directory, indexContent);
if (entries.length === 0) {
return { text: `No indexed entries found in ${directory}/INDEX.md.`, details: { directory, found: false, reason: "empty_index" } };
return { text: `No indexed entries found in ${directory}${path.sep}INDEX.md.`, details: { directory, found: false, reason: "empty_index" } };
}

const scored = entries
Expand All @@ -279,7 +297,7 @@ export function resolveIndexedFile(config: MemoryConfig, directory: string, quer

if (scored.length === 0) {
return {
text: formatIndexCandidates(directory, entries, `No indexed entry matched "${query}" in ${directory}/INDEX.md. Candidates:`),
text: formatIndexCandidates(directory, entries, `No indexed entry matched "${query}" in ${directory}${path.sep}INDEX.md. Candidates:`),
details: { directory, query, found: false, reason: "no_match", candidates: entries.map(e => e.filename) },
};
}
Expand All @@ -300,11 +318,11 @@ export function resolveIndexedFile(config: MemoryConfig, directory: string, quer
const content = readFileSafe(filePath);
if (!content) {
return {
text: `INDEX.md points to missing file: ${directory}/${match.filename}`,
text: `INDEX.md points to missing file: ${directory}${path.sep}${match.filename}`,
details: { directory, query, found: false, reason: "missing_resolved_file", filename: match.filename, path: filePath },
};
}
return { text: content, details: { path: filePath, filename: `${directory}/${match.filename}`, resolvedFrom: query, title: match.title } };
return { text: content, details: { path: filePath, filename: path.join(directory, match.filename), resolvedFrom: query, title: match.title } };
}

export function readMemoryFile(config: MemoryConfig, filename: string): MemoryReadResult {
Expand Down
102 changes: 60 additions & 42 deletions tests/config.test.ts
Original file line number Diff line number Diff line change
@@ -1,109 +1,113 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import * as os from "node:os";
import * as path from "node:path";
import { buildConfig, resolveAgentDir, resolveHomeDir, resolveSessionsDir } from "../lib.ts";
import { makeTempDir, cleanup, writeFile } from "./helpers.ts";

describe("buildConfig", () => {
it("uses defaults when no env vars set", () => {
const config = buildConfig({ HOME: "/home/testuser" });
assert.strictEqual(config.memoryDir, "/home/testuser/.pi/agent/memory");
assert.strictEqual(config.memoryFile, "/home/testuser/.pi/agent/memory/MEMORY.md");
assert.strictEqual(config.scratchpadFile, "/home/testuser/.pi/agent/memory/SCRATCHPAD.md");
assert.strictEqual(config.dailyDir, "/home/testuser/.pi/agent/memory/daily");
assert.strictEqual(config.notesDir, "/home/testuser/.pi/agent/memory/notes");
const config = buildConfig({ HOME: path.normalize("/home/testuser") });
assert.strictEqual(config.memoryDir, path.normalize("/home/testuser/.pi/agent/memory"));
assert.strictEqual(config.memoryFile, path.normalize("/home/testuser/.pi/agent/memory/MEMORY.md"));
assert.strictEqual(config.scratchpadFile, path.normalize("/home/testuser/.pi/agent/memory/SCRATCHPAD.md"));
assert.strictEqual(config.dailyDir, path.normalize("/home/testuser/.pi/agent/memory/daily"));
assert.strictEqual(config.notesDir, path.normalize("/home/testuser/.pi/agent/memory/notes"));
assert.deepStrictEqual(config.contextFiles, []);
assert.strictEqual(config.autocommit, false);
assert.strictEqual(config.timezone, "UTC");
});

it("respects PI_MEMORY_DIR override", () => {
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: "/custom/mem" });
assert.strictEqual(config.memoryDir, "/custom/mem");
assert.strictEqual(config.memoryFile, "/custom/mem/MEMORY.md");
assert.strictEqual(config.scratchpadFile, "/custom/mem/SCRATCHPAD.md");
assert.strictEqual(config.notesDir, "/custom/mem/notes");
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_MEMORY_DIR: path.normalize("/custom/mem") });
assert.strictEqual(config.memoryDir, path.normalize("/custom/mem"));
assert.strictEqual(config.memoryFile, path.normalize("/custom/mem/MEMORY.md"));
assert.strictEqual(config.scratchpadFile, path.normalize("/custom/mem/SCRATCHPAD.md"));
assert.strictEqual(config.notesDir, path.normalize("/custom/mem/notes"));
});

it("respects PI_DAILY_DIR override independently of memory dir", () => {
const config = buildConfig({ HOME: "/home/x", PI_DAILY_DIR: "/other/daily" });
assert.strictEqual(config.dailyDir, "/other/daily");
assert.strictEqual(config.memoryDir, "/home/x/.pi/agent/memory");
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_DAILY_DIR: path.normalize("/other/daily") });
assert.strictEqual(config.dailyDir, path.normalize("/other/daily"));
assert.strictEqual(config.memoryDir, path.normalize("/home/x/.pi/agent/memory"));
});

it("parses PI_CONTEXT_FILES as comma-separated list", () => {
const config = buildConfig({ HOME: "/home/x", PI_CONTEXT_FILES: "SOUL.md, AGENTS.md, HEARTBEAT.md" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_CONTEXT_FILES: "SOUL.md, AGENTS.md, HEARTBEAT.md" });
assert.deepStrictEqual(config.contextFiles, ["SOUL.md", "AGENTS.md", "HEARTBEAT.md"]);
});

it("handles empty PI_CONTEXT_FILES", () => {
const config = buildConfig({ HOME: "/home/x", PI_CONTEXT_FILES: "" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_CONTEXT_FILES: "" });
assert.deepStrictEqual(config.contextFiles, []);
});

it("handles PI_CONTEXT_FILES with extra whitespace and trailing comma", () => {
const config = buildConfig({ HOME: "/home/x", PI_CONTEXT_FILES: " A.md , B.md , " });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_CONTEXT_FILES: " A.md , B.md , " });
assert.deepStrictEqual(config.contextFiles, ["A.md", "B.md"]);
});

it("enables autocommit with PI_AUTOCOMMIT=1", () => {
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "1" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_AUTOCOMMIT: "1" });
assert.strictEqual(config.autocommit, true);
});

it("enables autocommit with PI_AUTOCOMMIT=true", () => {
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "true" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_AUTOCOMMIT: "true" });
assert.strictEqual(config.autocommit, true);
});

it("does not enable autocommit with PI_AUTOCOMMIT=0", () => {
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "0" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_AUTOCOMMIT: "0" });
assert.strictEqual(config.autocommit, false);
});

it("does not enable autocommit with PI_AUTOCOMMIT=yes", () => {
const config = buildConfig({ HOME: "/home/x", PI_AUTOCOMMIT: "yes" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_AUTOCOMMIT: "yes" });
assert.strictEqual(config.autocommit, false);
});

it("parses PI_SEARCH_DIRS as comma-separated list", () => {
const config = buildConfig({ HOME: "/home/x", PI_SEARCH_DIRS: "catchup, projects" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_SEARCH_DIRS: "catchup, projects" });
assert.deepStrictEqual(config.searchDirs, ["catchup", "projects"]);
});

it("uses PI_TIMEZONE before TZ", () => {
const config = buildConfig({ HOME: "/home/x", TZ: "UTC", PI_TIMEZONE: "America/Los_Angeles" });
const config = buildConfig({ HOME: path.normalize("/home/x"), TZ: "UTC", PI_TIMEZONE: "America/Los_Angeles" });
assert.strictEqual(config.timezone, "America/Los_Angeles");
});

it("falls back to TZ for timezone", () => {
const config = buildConfig({ HOME: "/home/x", TZ: "America/New_York" });
const config = buildConfig({ HOME: path.normalize("/home/x"), TZ: "America/New_York" });
assert.strictEqual(config.timezone, "America/New_York");
});

it("falls back to UTC for invalid timezone", () => {
const config = buildConfig({ HOME: "/home/x", PI_TIMEZONE: "not/a-zone", TZ: "also-bad" });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_TIMEZONE: "not/a-zone", TZ: "also-bad" });
assert.strictEqual(config.timezone, "UTC");
});

it("defaults PI_SEARCH_DIRS to empty array", () => {
const config = buildConfig({ HOME: "/home/x" });
const config = buildConfig({ HOME: path.normalize("/home/x") });
assert.deepStrictEqual(config.searchDirs, []);
});

it("handles PI_SEARCH_DIRS with extra whitespace and trailing comma", () => {
const config = buildConfig({ HOME: "/home/x", PI_SEARCH_DIRS: " catchup , projects , " });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_SEARCH_DIRS: " catchup , projects , " });
assert.deepStrictEqual(config.searchDirs, ["catchup", "projects"]);
});

it("reads .pi-mem.json from memory dir", () => {
const memDir = makeTempDir();
writeFile(path.join(memDir, ".pi-mem.json"), JSON.stringify({
writeFile(
path.join(memDir, ".pi-mem.json"),
JSON.stringify({
searchDirs: ["catchup", "projects"],
contextFiles: ["SOUL.md"],
autocommit: true,
}));
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: memDir });
}),
);
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_MEMORY_DIR: memDir });
assert.deepStrictEqual(config.searchDirs, ["catchup", "projects"]);
assert.deepStrictEqual(config.contextFiles, ["SOUL.md"]);
assert.strictEqual(config.autocommit, true);
Expand All @@ -112,13 +116,16 @@ describe("buildConfig", () => {

it("env vars override .pi-mem.json values", () => {
const memDir = makeTempDir();
writeFile(path.join(memDir, ".pi-mem.json"), JSON.stringify({
writeFile(
path.join(memDir, ".pi-mem.json"),
JSON.stringify({
searchDirs: ["catchup"],
contextFiles: ["SOUL.md"],
autocommit: true,
}));
}),
);
const config = buildConfig({
HOME: "/home/x",
HOME: path.normalize("/home/x"),
PI_MEMORY_DIR: memDir,
PI_SEARCH_DIRS: "projects,other",
PI_CONTEXT_FILES: "AGENTS.md",
Expand All @@ -133,7 +140,7 @@ describe("buildConfig", () => {
it("ignores malformed .pi-mem.json", () => {
const memDir = makeTempDir();
writeFile(path.join(memDir, ".pi-mem.json"), "not json{{");
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: memDir });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_MEMORY_DIR: memDir });
assert.deepStrictEqual(config.searchDirs, []);
assert.deepStrictEqual(config.contextFiles, []);
cleanup(memDir);
Expand All @@ -146,29 +153,40 @@ describe("buildConfig", () => {
contextFiles: 42,
autocommit: "yes",
}));
const config = buildConfig({ HOME: "/home/x", PI_MEMORY_DIR: memDir });
const config = buildConfig({ HOME: path.normalize("/home/x"), PI_MEMORY_DIR: memDir });
assert.deepStrictEqual(config.searchDirs, []);
assert.deepStrictEqual(config.contextFiles, []);
assert.strictEqual(config.autocommit, false);
cleanup(memDir);
});

it("uses the supplied platform fallback when home variables are absent", () => {
assert.strictEqual(resolveHomeDir({}, "/fallback/home"), "/fallback/home");
assert.strictEqual(resolveAgentDir({}, "/fallback/home"), "/fallback/home/.pi/agent");
assert.strictEqual(resolveHomeDir({}, path.normalize("/fallback/home")), path.normalize("/fallback/home"));
assert.strictEqual(resolveAgentDir({}, path.normalize("/fallback/home")), path.normalize("/fallback/home/.pi/agent"));
});

it("uses os.homedir() when fallback arguments are not provided", () => {
assert.strictEqual(resolveHomeDir({}), os.homedir());
assert.strictEqual(resolveAgentDir({}), path.join(os.homedir(), ".pi", "agent"));
});

it("uses os.homedir() when fallback is null or undefined", () => {
assert.strictEqual(resolveHomeDir({}, null as unknown as string), os.homedir());
assert.strictEqual(resolveAgentDir({}, null as unknown as string), path.join(os.homedir(), ".pi", "agent"));
assert.strictEqual(resolveSessionsDir({}, null as unknown as string), path.join(os.homedir(), ".pi", "agent", "sessions"));
});

it("supports Windows USERPROFILE", () => {
assert.strictEqual(resolveHomeDir({ USERPROFILE: "C:\\Users\\test" }, "/fallback"), "C:\\Users\\test");
assert.strictEqual(resolveHomeDir({ USERPROFILE: "C:\\Users\\test" }, "/fallback"), path.normalize("C:\\Users\\test"));
});

it("supports Windows HOMEDRIVE and HOMEPATH", () => {
assert.strictEqual(resolveHomeDir({ HOMEDRIVE: "C:", HOMEPATH: "\\Users\\test" }, "/fallback"), "C:\\Users\\test");
assert.strictEqual(resolveHomeDir({ HOMEDRIVE: "C:", HOMEPATH: "\\Users\\test" }, "/fallback"), path.normalize("C:\\Users\\test"));
});

it("respects PI_CODING_AGENT_DIR for memory and sessions", () => {
const env = { HOME: "/home/x", PI_CODING_AGENT_DIR: "/custom/agent" };
assert.strictEqual(buildConfig(env).memoryDir, "/custom/agent/memory");
assert.strictEqual(resolveSessionsDir(env), "/custom/agent/sessions");
const env = { HOME: path.normalize("/home/x"), PI_CODING_AGENT_DIR: path.normalize("/custom/agent") };
assert.strictEqual(buildConfig(env).memoryDir, path.normalize("/custom/agent/memory"));
assert.strictEqual(resolveSessionsDir(env), path.normalize("/custom/agent/sessions"));
});
});
20 changes: 15 additions & 5 deletions tests/date-helpers.test.ts
Original file line number Diff line number Diff line change
@@ -1,6 +1,13 @@
import { describe, it } from "node:test";
import assert from "node:assert";
import { todayStr, yesterdayStr, nowTimestamp, shortSessionId, dailyPath } from "../lib.ts";
import {
todayStr,
yesterdayStr,
nowTimestamp,
shortSessionId,
dailyPath,
} from "../lib.ts";
import path from "node:path";

describe("todayStr", () => {
it("returns YYYY-MM-DD format", () => {
Expand Down Expand Up @@ -59,7 +66,10 @@ describe("shortSessionId", () => {
});

it("handles UUID-style IDs", () => {
assert.strictEqual(shortSessionId("550e8400-e29b-41d4-a716-446655440000"), "550e8400");
assert.strictEqual(
shortSessionId("550e8400-e29b-41d4-a716-446655440000"),
"550e8400",
);
});

it("handles short input gracefully", () => {
Expand All @@ -73,12 +83,12 @@ describe("shortSessionId", () => {

describe("dailyPath", () => {
it("builds correct path for a date", () => {
const result = dailyPath("/mem/daily", "2026-02-18");
assert.strictEqual(result, "/mem/daily/2026-02-18.md");
const result = dailyPath(path.normalize("/mem/daily"), "2026-02-18");
assert.strictEqual(result, path.normalize("/mem/daily/2026-02-18.md"));
});

it("handles trailing slash in dir", () => {
const result = dailyPath("/mem/daily/", "2026-01-01");
const result = dailyPath(path.normalize("/mem/daily"), "2026-01-01");
assert.ok(result.endsWith("2026-01-01.md"));
});
});
Loading