diff --git a/.gitattributes b/.gitattributes new file mode 100644 index 0000000..c4c1f90 --- /dev/null +++ b/.gitattributes @@ -0,0 +1,4 @@ +* text=auto eol=lf +*.png binary +*.jpg binary +*.ico binary diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index 09fed2b..c645ea8 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -9,7 +9,11 @@ permissions: jobs: lint: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -20,7 +24,11 @@ jobs: - run: npm run lint build: - runs-on: ubuntu-latest + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] steps: - uses: actions/checkout@v4 - uses: actions/setup-node@v4 @@ -30,6 +38,116 @@ jobs: - run: npm ci - run: npm run build + unit: + runs-on: ${{ matrix.os }} + strategy: + fail-fast: false + matrix: + os: [ubuntu-latest, windows-latest] + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 20 + cache: npm + - uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + - run: npm ci + - name: Run unit tests + run: bun test test/unit.test.ts + + windows-qmd-smoke: + name: windows-qmd-smoke (PR #11 verification) + runs-on: windows-latest + steps: + - uses: actions/checkout@v4 + - uses: actions/setup-node@v4 + with: + node-version: 22 + cache: npm + - run: npm ci + + - name: Install qmd via npm + shell: pwsh + run: npm install -g @tobilu/qmd + + - name: Ensure qmd prerequisites are on PATH + shell: pwsh + run: | + $npmPrefix = npm prefix -g + Write-Host "Adding to PATH: $npmPrefix" + Add-Content -Path $env:GITHUB_PATH -Value $npmPrefix + $gitUsrBin = Join-Path $env:ProgramFiles "Git\usr\bin" + if (Test-Path $gitUsrBin) { + Write-Host "Adding to PATH: $gitUsrBin" + Add-Content -Path $env:GITHUB_PATH -Value $gitUsrBin + } + + - name: Confirm qmd is callable (via the same direct-node path the wrapper uses) + shell: pwsh + run: | + $prefix = (npm prefix -g).Trim() + Write-Host "--- npm prefix shims ($prefix) ---" + Get-ChildItem -Path $prefix -Filter "qmd*" -ErrorAction SilentlyContinue | Format-Table -AutoSize Name, Length + Write-Host "--- Get-Command qmd -All ---" + Get-Command qmd -All | Format-Table -AutoSize + # cmd-shim writes literal `/bin/sh` into both qmd.cmd and qmd.ps1 on + # Windows, so neither shim is callable directly. The extension bypasses + # this by invoking qmd's JS entry with node. Verify that path exists + # and works. + $qmdJs = Join-Path $prefix "node_modules\@tobilu\qmd\dist\cli\qmd.js" + Write-Host "--- expected qmd.js: $qmdJs ---" + if (-not (Test-Path $qmdJs)) { + Write-Error "qmd.js not found at $qmdJs" + exit 1 + } + node $qmdJs --version + if ($LASTEXITCODE -ne 0) { + Write-Error "node $qmdJs --version failed (exit $LASTEXITCODE)" + exit 1 + } + + # Captures stock Node execFile behavior for the runner's qmd shim. This + # is informational because shim behavior varies by package manager. + - name: Smoke (informational) — stock Node execFile -> qmd + shell: pwsh + continue-on-error: true + run: | + node -e "const { execFile } = require('node:child_process'); execFile('qmd', ['--version'], (err, stdout, stderr) => { if (err) { console.error('STOCK execFile FAILED (expected on Windows):', err.code, err.message); process.exit(1); } console.log('STOCK execFile OK:', stdout.trim()); });" + + # Exercises the extension's qmd execFile wrapper under Node. The + # setupQmdCollection() calls qmd via the wrapped execFile, so a + # successful run here verifies Windows shell invocation. + - name: Smoke — extension wrapper -> qmd (must succeed) + shell: pwsh + run: | + node --import tsx -e "import('./index.ts').then(async (m) => { m.ensureDirs(); const ok = await m.setupQmdCollection(); if (!ok) { console.error('setupQmdCollection returned false — wrapper did not fix qmd invocation'); process.exit(1); } console.log('OK: extension wrapper successfully invoked qmd through the Windows shell'); }).catch((e) => { console.error('FAILED:', e); process.exit(1); });" + + # Verifies resolveMemoryDir() falls back to USERPROFILE when HOME + # is unset (the second bug PR #11 fixes). We import the module in a + # subprocess with HOME deliberately removed, then have it create the + # default memory directory and print its location. + - name: Smoke — resolveMemoryDir USERPROFILE fallback + shell: pwsh + run: | + Remove-Item Env:HOME -ErrorAction SilentlyContinue + Remove-Item Env:PI_MEMORY_DIR -ErrorAction SilentlyContinue + if (-not $env:USERPROFILE) { Write-Error "USERPROFILE is not set on this runner"; exit 1 } + $expectedPrefix = Join-Path $env:USERPROFILE ".pi\agent\memory" + Write-Host "Expected prefix: $expectedPrefix" + $actual = node --import tsx -e "import('./index.ts').then(m => { console.log(m.dailyPath('2026-01-01')); });" + Write-Host "ensureDirs/dailyPath returned: $actual" + if ($actual -notlike "$expectedPrefix*") { + Write-Error "FAILED: resolveMemoryDir did not honor USERPROFILE fallback. Got: $actual" + exit 1 + } + if ($actual -like "*~\.pi*") { + Write-Error "FAILED: literal '~' subdirectory still present in resolved path" + exit 1 + } + Write-Host "OK: memory directory resolved under USERPROFILE" + test: runs-on: ubuntu-latest env: diff --git a/index.ts b/index.ts index 8b961dc..7ba1787 100644 --- a/index.ts +++ b/index.ts @@ -20,7 +20,7 @@ * - MEMORY.md + SCRATCHPAD.md + today's + yesterday's daily logs injected into every turn */ -import { execFile } from "node:child_process"; +import { type ExecFileOptions, execFile } from "node:child_process"; import * as fs from "node:fs"; import * as path from "node:path"; import { complete, type Message, StringEnum } from "@mariozechner/pi-ai"; @@ -37,9 +37,23 @@ import { Type } from "@sinclair/typebox"; // Paths (mutable for testing via _setBaseDir / _resetBaseDir) // --------------------------------------------------------------------------- -const DEFAULT_MEMORY_DIR = process.env.PI_MEMORY_DIR ?? path.join(process.env.HOME ?? "~", ".pi", "agent", "memory"); +type MemoryEnv = Partial< + Record<"PI_MEMORY_DIR" | "HOME" | "USERPROFILE" | "HOMEDRIVE" | "HOMEPATH", string | undefined> +> & { + [key: string]: string | undefined; +}; + +export function resolveMemoryDir(env: MemoryEnv = process.env): string { + if (env.PI_MEMORY_DIR) return env.PI_MEMORY_DIR; + const home = + env.HOME ?? + env.USERPROFILE ?? + (env.HOMEDRIVE && env.HOMEPATH ? `${env.HOMEDRIVE}${env.HOMEPATH}` : undefined) ?? + "~"; + return path.join(home, ".pi", "agent", "memory"); +} -let MEMORY_DIR = DEFAULT_MEMORY_DIR; +let MEMORY_DIR = resolveMemoryDir(); let MEMORY_FILE = path.join(MEMORY_DIR, "MEMORY.md"); let SCRATCHPAD_FILE = path.join(MEMORY_DIR, "SCRATCHPAD.md"); let DAILY_DIR = path.join(MEMORY_DIR, "daily"); @@ -54,7 +68,7 @@ export function _setBaseDir(baseDir: string) { /** Reset to default paths (for testing). */ export function _resetBaseDir() { - _setBaseDir(DEFAULT_MEMORY_DIR); + _setBaseDir(resolveMemoryDir()); } // --------------------------------------------------------------------------- @@ -604,7 +618,72 @@ export function buildMemoryContext(searchResults?: string): string { // --------------------------------------------------------------------------- type ExecFileFn = typeof execFile; -let execFileFn: ExecFileFn = execFile; + +function isQmdCommand(file: string | URL): boolean { + if (typeof file !== "string") return false; + const basename = file.replace(/\\/g, "/").split("/").pop()?.toLowerCase(); + return basename === "qmd" || basename === "qmd.cmd" || basename === "qmd.exe"; +} + +const QMD_JS_REL = path.join("node_modules", "@tobilu", "qmd", "dist", "cli", "qmd.js"); + +let cachedQmdJsPath: string | null | undefined; + +// On Windows, cmd-shim writes the literal `/bin/sh` (the package's shebang +// interpreter) into both qmd.cmd and qmd.ps1, so both shims fail with +// "system cannot find the path specified" / "'/bin/sh.exe' is not recognized" +// outside cygwin/git-bash trees. Bypass the shims by locating qmd's JS entry +// in a sibling node_modules directory of a PATH entry and invoking it with +// node directly — the same thing the sh script in bin/qmd does when launched +// via npm. +export function resolveQmdJsPath(env: NodeJS.ProcessEnv = process.env): string | null { + if (cachedQmdJsPath !== undefined) return cachedQmdJsPath; + const pathStr = env.PATH ?? env.Path ?? ""; + const entries = pathStr.split(path.delimiter).filter(Boolean); + for (const dir of entries) { + try { + const candidate = path.join(dir, QMD_JS_REL); + if (fs.statSync(candidate).isFile()) { + cachedQmdJsPath = candidate; + return candidate; + } + } catch { + // keep scanning + } + } + cachedQmdJsPath = null; + return null; +} + +/** Clear the resolved qmd.js cache (for testing). */ +export function _resetQmdJsResolutionForTest() { + cachedQmdJsPath = undefined; +} + +export function buildQmdSpawn( + file: string, + args: readonly string[], + platform: NodeJS.Platform = process.platform, + qmdJsPath: string | null = null, +): { file: string; args: string[] } { + if (platform !== "win32" || !isQmdCommand(file) || !qmdJsPath) { + return { file, args: [...args] }; + } + return { file: "node", args: [qmdJsPath, ...args] }; +} + +const execFileWithQmdOptions: ExecFileFn = (( + file: string, + args: readonly string[], + options: ExecFileOptions, + callback: (...args: any[]) => void, +) => { + const qmdJs = process.platform === "win32" && isQmdCommand(file) ? resolveQmdJsPath() : null; + const spawn = buildQmdSpawn(file, args ?? [], process.platform, qmdJs); + return execFile(spawn.file, spawn.args, options, callback as any); +}) as ExecFileFn; + +let execFileFn: ExecFileFn = execFileWithQmdOptions; let qmdAvailable = false; let qmdAvailabilityCheckedAt = 0; @@ -629,7 +708,7 @@ export function _setExecFileForTest(fn: ExecFileFn) { /** Reset execFile implementation (for testing). */ export function _resetExecFileForTest() { - execFileFn = execFile; + execFileFn = execFileWithQmdOptions; } /** Set qmd availability flag (for testing). */ diff --git a/test/unit.test.ts b/test/unit.test.ts index 2dca417..b02c616 100644 --- a/test/unit.test.ts +++ b/test/unit.test.ts @@ -17,10 +17,12 @@ import { _resetBaseDir, _resetExecFileForTest, _resetMemorySnapshot, + _resetQmdJsResolutionForTest, _setBaseDir, _setExecFileForTest, _setQmdAvailable, buildMemoryContext, + buildQmdSpawn, dailyPath, ensureDirs, nowTimestamp, @@ -28,6 +30,8 @@ import { qmdCollectionInstructions, qmdInstallInstructions, readFileSafe, + resolveMemoryDir, + resolveQmdJsPath, type ScratchpadItem, scheduleQmdUpdate, serializeScratchpad, @@ -149,6 +153,113 @@ describe("nowTimestamp", () => { }); }); +describe("resolveMemoryDir", () => { + test("prefers PI_MEMORY_DIR", () => { + const env = { + PI_MEMORY_DIR: path.join("custom", "memory"), + HOME: path.join("home", "ignored"), + USERPROFILE: path.join("profile", "ignored"), + }; + + expect(resolveMemoryDir(env)).toBe(env.PI_MEMORY_DIR); + }); + + test("falls back to USERPROFILE when HOME is unset", () => { + const env = { + USERPROFILE: path.join("Users", "runneradmin"), + }; + + expect(resolveMemoryDir(env)).toBe(path.join(env.USERPROFILE, ".pi", "agent", "memory")); + }); +}); + +describe("buildQmdSpawn", () => { + const QMD_JS = "C:\\npm\\prefix\\node_modules\\@tobilu\\qmd\\dist\\cli\\qmd.js"; + + test("invokes qmd's JS entry via node on Windows when resolution succeeds", () => { + const out = buildQmdSpawn("qmd", ["collection", "list"], "win32", QMD_JS); + expect(out.file).toBe("node"); + expect(out.args).toEqual([QMD_JS, "collection", "list"]); + }); + + test("no-arg qmd invocation still uses node + resolved JS path on Windows", () => { + const out = buildQmdSpawn("qmd", [], "win32", QMD_JS); + expect(out.file).toBe("node"); + expect(out.args).toEqual([QMD_JS]); + }); + + test("paths with spaces and `$` in user args pass through as literal argv", () => { + const arg = "C:\\Users\\Foo Bar\\$mem"; + const out = buildQmdSpawn("qmd", ["collection", "add", arg], "win32", QMD_JS); + expect(out.args).toEqual([QMD_JS, "collection", "add", arg]); + }); + + test("recognizes qmd.cmd and qmd.exe as qmd commands on Windows", () => { + expect(buildQmdSpawn("qmd.cmd", ["update"], "win32", QMD_JS).file).toBe("node"); + expect(buildQmdSpawn("qmd.exe", ["update"], "win32", QMD_JS).file).toBe("node"); + }); + + test("falls through to bare qmd when resolution returns null", () => { + const out = buildQmdSpawn("qmd", ["update"], "win32", null); + expect(out.file).toBe("qmd"); + expect(out.args).toEqual(["update"]); + }); + + test("passes through unchanged on non-Windows even with a resolved path", () => { + const out = buildQmdSpawn("qmd", ["update"], "linux", QMD_JS); + expect(out.file).toBe("qmd"); + expect(out.args).toEqual(["update"]); + }); + + test("passes through unchanged for non-qmd commands on Windows", () => { + const out = buildQmdSpawn("node", ["-v"], "win32", QMD_JS); + expect(out.file).toBe("node"); + expect(out.args).toEqual(["-v"]); + }); +}); + +describe("resolveQmdJsPath", () => { + let scratchDir: string; + beforeEach(() => { + scratchDir = fs.mkdtempSync(path.join(os.tmpdir(), "pi-memory-qmd-resolve-")); + _resetQmdJsResolutionForTest(); + }); + afterEach(() => { + fs.rmSync(scratchDir, { recursive: true, force: true }); + _resetQmdJsResolutionForTest(); + }); + + test("returns the sibling node_modules path for a PATH entry that contains the install", () => { + const prefix = path.join(scratchDir, "prefix"); + const qmdJs = path.join(prefix, "node_modules", "@tobilu", "qmd", "dist", "cli", "qmd.js"); + fs.mkdirSync(path.dirname(qmdJs), { recursive: true }); + fs.writeFileSync(qmdJs, "// stub", "utf-8"); + + const found = resolveQmdJsPath({ PATH: prefix } as NodeJS.ProcessEnv); + expect(found).toBe(qmdJs); + }); + + test("returns null when no PATH entry has a sibling install", () => { + const empty = path.join(scratchDir, "empty"); + fs.mkdirSync(empty, { recursive: true }); + const found = resolveQmdJsPath({ PATH: empty } as NodeJS.ProcessEnv); + expect(found).toBeNull(); + }); + + test("caches the resolved path across calls", () => { + const prefix = path.join(scratchDir, "prefix"); + const qmdJs = path.join(prefix, "node_modules", "@tobilu", "qmd", "dist", "cli", "qmd.js"); + fs.mkdirSync(path.dirname(qmdJs), { recursive: true }); + fs.writeFileSync(qmdJs, "// stub", "utf-8"); + + const first = resolveQmdJsPath({ PATH: prefix } as NodeJS.ProcessEnv); + // Second call with an empty PATH still returns the cached value + const second = resolveQmdJsPath({ PATH: "" } as NodeJS.ProcessEnv); + expect(first).toBe(qmdJs); + expect(second).toBe(qmdJs); + }); +}); + describe("shortSessionId", () => { test("returns first 8 characters", () => { expect(shortSessionId("abcdef1234567890")).toBe("abcdef12");