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
1,048 changes: 1,048 additions & 0 deletions docs/todos/2026-06-19-issue-337-iii-0-18-compatibility/plan.md

Large diffs are not rendered by default.

295 changes: 295 additions & 0 deletions docs/todos/2026-06-19-issue-337-iii-0-18-compatibility/todo.md

Large diffs are not rendered by default.

129 changes: 83 additions & 46 deletions src/cli.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,10 +77,19 @@ import {
} from "./cli/stop-processes.js";
import {
findIiiConfigPath,
iiiReleaseAsset,
iiiReleaseUrl,
resolveDataDir,
} from "./cli/build-runtime.js";
import {
canAutoInstallIii,
displayIiiVersionOrNull,
healthyIiiVersionOrNull,
planIiiInstall,
planIiiStartupDecision,
probeIiiVersion,
resolveCompatibleIiiPath,
type IiiVersionProbe,
} from "./cli/iii-runtime.js";
import { prepareEngineLaunch } from "./cli/engine-launch.js";
import {
formatReadyTimeout,
Expand Down Expand Up @@ -468,18 +477,16 @@ function fallbackIiiPaths(): string[] {
return paths;
}

function iiiBinProbe(binPath: string): IiiVersionProbe {
return probeIiiVersion(binPath);
}

function iiiBinDisplayVersion(binPath: string): string | null {
return displayIiiVersionOrNull(iiiBinProbe(binPath));
}

function iiiBinVersion(binPath: string): string | null {
try {
const out = execFileSync(binPath, ["--version"], {
encoding: "utf-8",
stdio: ["ignore", "pipe", "ignore"],
timeout: 3000,
});
const match = out.match(/(\d+\.\d+\.\d+(?:[-+][\w.]+)?)/);
return match ? match[1]! : null;
} catch {
return null;
}
return healthyIiiVersionOrNull(iiiBinProbe(binPath));
}

// Resolve a compatible iii binary for the pinned engine version.
Expand All @@ -498,19 +505,20 @@ function iiiBinVersion(binPath: string): string | null {
// engine can opt in.
function resolveCompatibleIii(iiiBinPath: string | null | undefined): string | null {
if (!iiiBinPath) return null;
const detected = iiiBinVersion(iiiBinPath);
if (detected && detected === IIPINNED_VERSION) return iiiBinPath;

const privatePath = privateIiiPath();
if (iiiBinPath !== privatePath && existsSync(privatePath)) {
const privateVersion = iiiBinVersion(privatePath);
if (privateVersion === IIPINNED_VERSION) {
const reason = detected ? `v${detected} mismatches pin` : "probe failed";
vlog(
`iii at ${iiiBinPath} ${reason} v${IIPINNED_VERSION}; using private install at ${privatePath}.`,
);
return privatePath;
}
const resolution = resolveCompatibleIiiPath({
candidatePath: iiiBinPath,
privatePath: privateIiiPath(),
pinnedVersion: IIPINNED_VERSION,
});
if (resolution.kind === "candidate") return resolution.binPath;
if (resolution.kind === "private") {
const reason = resolution.candidateProbe.version
? `v${resolution.candidateProbe.version} mismatches pin`
: "probe failed";
vlog(
`iii at ${iiiBinPath} ${reason} v${IIPINNED_VERSION}; using private install at ${resolution.binPath}.`,
);
return resolution.binPath;
}

return null;
Expand Down Expand Up @@ -798,21 +806,19 @@ function adoptRunningEngine(): void {
}

async function runIiiInstaller(): Promise<{ ok: boolean; binPath: string | null }> {
const releaseUrl = pinnedIiiReleaseUrl();
const asset = iiiReleaseAsset();
const isZipAsset = asset?.endsWith(".zip") === true;
const installPlan = planIiiInstall({ version: IIPINNED_VERSION });

if (!releaseUrl) {
if (installPlan.kind === "unsupported-platform") {
p.log.warn(
`iii-engine binary not available for ${platform()}/${process.arch}. Use Docker (\`docker pull iiidev/iii:${IIPINNED_VERSION}\`) or download manually from https://github.com/iii-hq/iii/releases/tag/iii%2Fv${IIPINNED_VERSION}.`,
);
return { ok: false, binPath: null };
}

if (IS_WINDOWS || isZipAsset) {
if (!canAutoInstallIii(installPlan)) {
p.log.info(
`Auto-install unavailable on ${platform()} — ${asset} isn't tar-compatible. Install manually:\n` +
` 1. Download ${releaseUrl}\n` +
`Auto-install unavailable on ${platform()} — ${installPlan.asset} isn't tar-compatible. Install manually:\n` +
` 1. Download ${installPlan.releaseUrl}\n` +
` 2. Extract iii.exe and place it on PATH (e.g. %USERPROFILE%\\.local\\bin)\n` +
`Or use Docker: docker pull iiidev/iii:${IIPINNED_VERSION}`,
);
Expand All @@ -830,7 +836,7 @@ async function runIiiInstaller(): Promise<{ ok: boolean; binPath: string | null
const binPath = privateIiiPath();
const installCmd = [
`mkdir -p "${binDir}"`,
`curl -fsSL "${releaseUrl}" | tar -xz -C "${binDir}"`,
`curl -fsSL "${installPlan.releaseUrl}" | tar -xz -C "${binDir}"`,
`chmod +x "${binPath}"`,
].join(" && ");
const installerOk = runCommand(shBin, ["-c", installCmd], {
Expand Down Expand Up @@ -1028,7 +1034,7 @@ async function startEngine(options: StartEngineOptions = {}): Promise<boolean> {

const fallbacks = fallbackIiiPaths().filter((p) => existsSync(p));
for (const f of fallbacks) {
const v = iiiBinVersion(f);
const v = iiiBinDisplayVersion(f);
vlog(`fallback iii at ${f} reports version: ${v ?? "unknown"}`);
}

Expand All @@ -1043,7 +1049,7 @@ async function startEngine(options: StartEngineOptions = {}): Promise<boolean> {
}

if (pathIii && !iiiBin) {
const detected = iiiBinVersion(pathIii);
const detected = iiiBinDisplayVersion(pathIii);
vlog(
`iii on PATH is v${detected ?? "unknown"}, pin is v${IIPINNED_VERSION}. ` +
`Will install pinned engine to ${privateIiiPath()}.`,
Expand All @@ -1069,24 +1075,53 @@ async function startEngine(options: StartEngineOptions = {}): Promise<boolean> {
process.env["AGENTMEMORY_USE_DOCKER"] === "1" ||
process.env["AGENTMEMORY_USE_DOCKER"] === "true";
const interactive = !!process.stdin.isTTY && !process.env["CI"];
const installPlan = planIiiInstall({ version: IIPINNED_VERSION });
const dockerAvailable = Boolean(dockerBin && composeFile);
const pathIiiVersion = pathIii ? iiiBinDisplayVersion(pathIii) : null;
const startupDecision = planIiiStartupDecision({
compatibleIii: iiiBin,
pathIii,
pathIiiVersion,
pinnedVersion: IIPINNED_VERSION,
installPlan,
dockerAvailable,
dockerOptIn,
interactive,
});

type Choice = "install" | "docker" | "manual";
let choice: Choice;

// Wrong-version iii on PATH is a configuration trap: any prompt would
// confuse the user since they already "have iii installed". Skip the
// prompt and auto-install pinned engine to the private location.
const pathIiiMismatch = pathIii !== null && resolveCompatibleIii(pathIii) === null;
// confuse the user since they already "have iii installed". Auto-install
// only where the pinned release asset is tar-compatible; otherwise show
// truthful manual/Docker guidance and leave the PATH iii untouched.
const pathIiiMismatch = pathIii !== null && iiiBin === null;

if (dockerOptIn && dockerBin && composeFile) {
if (startupDecision.kind === "docker" && dockerBin && composeFile) {
choice = "docker";
} else if (pathIiiMismatch) {
} else if (pathIiiMismatch && startupDecision.kind === "install") {
choice = "install";
const detected = iiiBinVersion(pathIii!);
p.log.info(
`iii on PATH is v${detected ?? "unknown"} but agentmemory pins v${IIPINNED_VERSION}. ` +
`Installing pinned engine to ~/.agentmemory/bin (leaves your existing iii untouched).`,
);
p.log.info(startupDecision.notice.join("\n"));
} else if (startupDecision.kind === "prompt-docker") {
p.log.info(startupDecision.notice.join("\n"));
const fallback = await p.confirm({
message: "Automatic native install is unavailable here. Try Docker compose instead?",
initialValue: true,
});
if (p.isCancel(fallback) || fallback !== true) {
startupFailure = { kind: "no-engine" };
return false;
}
choice = "docker";
} else if (pathIiiMismatch) {
p.log.info(startupDecision.notice.join("\n"));
startupFailure = { kind: "no-engine" };
return false;
} else if (startupDecision.kind === "manual" && startupDecision.notice.length > 0) {
p.log.info(startupDecision.notice.join("\n"));
startupFailure = { kind: "no-engine" };
return false;
} else if (!interactive) {
choice = "install";
p.log.info("Non-interactive environment detected — auto-installing iii-engine.");
Expand Down Expand Up @@ -1436,7 +1471,7 @@ async function main() {
fallbackIiiPaths().find((p) => existsSync(p)) ??
null;
if (attachedBin) {
const detected = iiiBinVersion(attachedBin);
const detected = iiiBinDisplayVersion(attachedBin);
if (detected && detected !== IIPINNED_VERSION) {
p.log.warn(
`iii on PATH is v${detected} (from ${attachedBin}) but agentmemory v${VERSION} pins v${IIPINNED_VERSION}. ` +
Expand Down Expand Up @@ -1788,6 +1823,8 @@ function buildDoctorEffects(): DoctorEffects {
findIiiBinary: () => whichBinary("iii"),
localBinIiiPath: () => privateIiiPath(),
iiiBinaryVersion: (binPath: string) => iiiBinVersion(binPath),
iiiBinaryProbe: (binPath: string) => iiiBinProbe(binPath),
iiiInstallPlan: () => planIiiInstall({ version: IIPINNED_VERSION }),
viewerReachable: async (timeoutMs = 2000) => {
try {
await discoverViewerPort();
Expand Down
77 changes: 68 additions & 9 deletions src/cli/doctor-diagnostics.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,14 @@
// agentmemory doctor --all # apply every available fix without prompting (CI)
// agentmemory doctor --dry-run # show what each fix WOULD do; execute nothing

import {
canAutoInstallIii,
displayIiiVersionOrNull,
healthyIiiVersionOrNull,
type IiiInstallPlan,
type IiiVersionProbe,
} from "./iii-runtime.js";

export type DiagnosticStatus = {
ok: boolean;
/** Short status detail (one line). Shown alongside the check name. */
Expand Down Expand Up @@ -211,6 +219,10 @@ export type DoctorEffects = {
localBinIiiPath: () => string;
/** Run `iii --version`; null if it fails. */
iiiBinaryVersion: (binPath: string) => string | null;
/** Run `iii --version` and return probe health plus parsed output. */
iiiBinaryProbe?: (binPath: string) => IiiVersionProbe;
/** Native iii install support for this platform/version. */
iiiInstallPlan?: () => IiiInstallPlan;
/** Probe the viewer URL; true if it returns OK within timeoutMs. */
viewerReachable: (timeoutMs?: number) => Promise<boolean>;
/** Run init logic (copies .env.example). */
Expand All @@ -228,6 +240,32 @@ export type DoctorEffects = {
};

export function buildDiagnostics(effects: DoctorEffects): Diagnostic[] {
const installPlan = effects.iiiInstallPlan?.();
const autoInstallAvailable = installPlan ? canAutoInstallIii(installPlan) : true;
const probeFor = (binPath: string): IiiVersionProbe => {
const probe = effects.iiiBinaryProbe?.(binPath);
if (probe) return probe;
const version = effects.iiiBinaryVersion(binPath);
return {
ok: version !== null,
version,
stdout: "",
stderr: "",
status: version === null ? null : 0,
signal: null,
};
};
const displayVersion = (probe: IiiVersionProbe): string | null =>
displayIiiVersionOrNull(probe);
const healthyVersion = (probe: IiiVersionProbe): string | null =>
healthyIiiVersionOrNull(probe);
const probeDetail = (probe: IiiVersionProbe): string => {
if (probe.version) {
return probe.ok ? probe.version : `${probe.version} (--version failed)`;
}
return "--version could not be parsed";
};

return [
{
id: "env-missing",
Expand Down Expand Up @@ -267,33 +305,53 @@ export function buildDiagnostics(effects: DoctorEffects): Diagnostic[] {
{
id: "engine-version-mismatch",
message: "No iii binary matches the version agentmemory pins to.",
fixPreview:
"Re-run the iii installer for the pinned version and restart the engine.",
fixPreview: autoInstallAvailable
? "Install the pinned version to ~/.agentmemory/bin and restart the engine."
: "Install the pinned version manually or run with AGENTMEMORY_USE_DOCKER=1; automatic native install is unavailable.",
moreInfo:
"agentmemory pins the iii engine to a specific release because newer engines " +
"use a different worker model. Running a mismatched binary surfaces as EPIPE " +
"reconnect loops and empty search results. At runtime agentmemory prefers the " +
"private install at ~/.agentmemory/bin/iii when the iii on PATH mismatches the pin.",
manualOnly: !autoInstallAvailable,
check: async (ctx) => {
const localBin = effects.localBinIiiPath();
const localVersion = effects.iiiBinaryVersion(localBin);
const localProbe = probeFor(localBin);
const localVersion = healthyVersion(localProbe);
const localDisplayVersion = displayVersion(localProbe);
const bin = effects.findIiiBinary();
const pathVersion = bin ? effects.iiiBinaryVersion(bin) : null;
const pathProbe = bin ? probeFor(bin) : null;
const pathVersion = pathProbe ? healthyVersion(pathProbe) : null;
const pathDisplayVersion = pathProbe ? displayVersion(pathProbe) : null;
if (localVersion === ctx.pinnedVersion) {
const note =
pathVersion && pathVersion !== ctx.pinnedVersion
pathDisplayVersion && pathDisplayVersion !== ctx.pinnedVersion
? "; PATH iii is different but the private install wins at runtime"
: "";
return {
ok: true,
detail: `private install ${localVersion} (pinned ${ctx.pinnedVersion})${note}`,
};
}
if (pathVersion === ctx.pinnedVersion) {
return {
ok: true,
detail: `PATH iii ${pathVersion} (pinned ${ctx.pinnedVersion})`,
};
}
if (localDisplayVersion === ctx.pinnedVersion) {
return {
ok: false,
detail: `private install ${probeDetail(localProbe)} (pinned ${ctx.pinnedVersion})`,
};
}
if (!bin) return { ok: false, detail: "iii not on PATH and no private install" };
if (!pathVersion) return { ok: false, detail: "iii on PATH but --version failed" };
if (!pathProbe || !pathDisplayVersion) {
return { ok: false, detail: "iii on PATH but --version could not be parsed" };
}
return {
ok: pathVersion === ctx.pinnedVersion,
detail: `${pathVersion} (pinned ${ctx.pinnedVersion})`,
detail: `${pathVersion ?? probeDetail(pathProbe)} (pinned ${ctx.pinnedVersion})`,
};
},
fix: async () => {
Expand Down Expand Up @@ -386,16 +444,17 @@ export function buildDiagnostics(effects: DoctorEffects): Diagnostic[] {
if (!bin) return { ok: true, detail: "iii not on PATH (handled elsewhere)" };
const localBin = effects.localBinIiiPath();
if (bin === localBin) return { ok: true };
const localVersion = effects.iiiBinaryVersion(localBin);
const localVersion = healthyVersion(probeFor(localBin));
if (localVersion === ctx.pinnedVersion) {
return {
ok: true,
detail: `private install pinned; PATH iii at ${bin} stays untouched`,
};
}
const pathVersion = displayVersion(probeFor(bin));
return {
ok: false,
detail: `iii at: ${bin}`,
detail: pathVersion ? `iii at: ${bin} (${pathVersion})` : `iii at: ${bin}`,
};
},
fix: async () =>
Expand Down
Loading
Loading