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
4 changes: 4 additions & 0 deletions .gitattributes
Original file line number Diff line number Diff line change
@@ -0,0 +1,4 @@
* text=auto eol=lf
*.png binary
*.jpg binary
*.ico binary
122 changes: 120 additions & 2 deletions .github/workflows/ci.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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:
Expand Down
91 changes: 85 additions & 6 deletions index.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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";
Expand All @@ -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");
Expand All @@ -54,7 +68,7 @@ export function _setBaseDir(baseDir: string) {

/** Reset to default paths (for testing). */
export function _resetBaseDir() {
_setBaseDir(DEFAULT_MEMORY_DIR);
_setBaseDir(resolveMemoryDir());
}

// ---------------------------------------------------------------------------
Expand Down Expand Up @@ -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;
Expand All @@ -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). */
Expand Down
Loading
Loading