Skip to content
Closed
14 changes: 9 additions & 5 deletions distribution/npm/aicx/install.js
Original file line number Diff line number Diff line change
Expand Up @@ -92,24 +92,28 @@ function cleanupShadowDir(scope, targetVersion) {
}
}

function scanAicxShadows(installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll("aicx")));
function scanBinaryShadows(binaryName, installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll(binaryName)));
if (pathBinaries.length === 0) return;

console.warn("[AICX npm] Existing aicx binaries on PATH:");
console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`);
for (const path of pathBinaries) {
const version = commandOutput(path, ["--version"]) || "unknown";
console.warn(` ${path} -> ${version}`);
}

const resolved = pathBinaries[0];
if (resolved && resolved !== installedPath) {
console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package.");
console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`);
console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`);
console.warn(` PATH resolves to: ${resolved}`);
console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install.");
}
}

function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) {
scanBinaryShadows("aicx", installedAicxPath, targetVersion);
scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion);
if (envFlag("AICX_NPM_REPLACE_LOCAL")) {
cleanupShadowDir("local-bin", targetVersion);
cleanupShadowDir("cargo-bin", targetVersion);
Expand Down Expand Up @@ -155,4 +159,4 @@ if (hasError) {
process.exit(1);
}

scanAicxShadows(getBinaryPath("aicx"), VERSION);
scanAicxShadows(getBinaryPath("aicx"), getBinaryPath("aicx-mcp"), VERSION);
Original file line number Diff line number Diff line change
Expand Up @@ -130,24 +130,28 @@ function cleanupShadowDir(scope, targetVersion) {
}
}

function scanAicxShadows(installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll("aicx")));
function scanBinaryShadows(binaryName, installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll(binaryName)));
if (pathBinaries.length === 0) return;

console.warn("[AICX npm] Existing aicx binaries on PATH:");
console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`);
for (const path of pathBinaries) {
const version = commandOutput(path, ["--version"]) || "unknown";
console.warn(` ${path} -> ${version}`);
}

const resolved = pathBinaries[0];
if (resolved && resolved !== installedPath) {
console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package.");
console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`);
console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`);
console.warn(` PATH resolves to: ${resolved}`);
console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install.");
}
}

function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) {
scanBinaryShadows("aicx", installedAicxPath, targetVersion);
scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion);
if (envFlag("AICX_NPM_REPLACE_LOCAL")) {
cleanupShadowDir("local-bin", targetVersion);
cleanupShadowDir("cargo-bin", targetVersion);
Expand Down Expand Up @@ -232,7 +236,7 @@ async function install() {
const targetAicxMcp = join(__dirname, `aicx-mcp${exe}`);
if (existsSync(targetAicx) && existsSync(targetAicxMcp)) {
console.log(`Binaries already exist at ${__dirname}`);
scanAicxShadows(targetAicx, VERSION);
scanAicxShadows(targetAicx, targetAicxMcp, VERSION);
return;
}

Expand Down Expand Up @@ -264,7 +268,7 @@ async function install() {
rmSync(tempDir, { recursive: true, force: true });

console.log(`Successfully installed aicx binaries to ${__dirname}`);
scanAicxShadows(targetAicx, VERSION);
scanAicxShadows(targetAicx, targetAicxMcp, VERSION);
} catch (error) {
rmSync(tempDir, { recursive: true, force: true });
console.error(`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,24 +130,28 @@ function cleanupShadowDir(scope, targetVersion) {
}
}

function scanAicxShadows(installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll("aicx")));
function scanBinaryShadows(binaryName, installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll(binaryName)));
if (pathBinaries.length === 0) return;

console.warn("[AICX npm] Existing aicx binaries on PATH:");
console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`);
for (const path of pathBinaries) {
const version = commandOutput(path, ["--version"]) || "unknown";
console.warn(` ${path} -> ${version}`);
}

const resolved = pathBinaries[0];
if (resolved && resolved !== installedPath) {
console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package.");
console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`);
console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`);
console.warn(` PATH resolves to: ${resolved}`);
console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install.");
}
}

function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) {
scanBinaryShadows("aicx", installedAicxPath, targetVersion);
scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion);
if (envFlag("AICX_NPM_REPLACE_LOCAL")) {
cleanupShadowDir("local-bin", targetVersion);
cleanupShadowDir("cargo-bin", targetVersion);
Expand Down Expand Up @@ -232,7 +236,7 @@ async function install() {
const targetAicxMcp = join(__dirname, `aicx-mcp${exe}`);
if (existsSync(targetAicx) && existsSync(targetAicxMcp)) {
console.log(`Binaries already exist at ${__dirname}`);
scanAicxShadows(targetAicx, VERSION);
scanAicxShadows(targetAicx, targetAicxMcp, VERSION);
return;
}

Expand Down Expand Up @@ -264,7 +268,7 @@ async function install() {
rmSync(tempDir, { recursive: true, force: true });

console.log(`Successfully installed aicx binaries to ${__dirname}`);
scanAicxShadows(targetAicx, VERSION);
scanAicxShadows(targetAicx, targetAicxMcp, VERSION);
} catch (error) {
rmSync(tempDir, { recursive: true, force: true });
console.error(`
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -130,24 +130,28 @@ function cleanupShadowDir(scope, targetVersion) {
}
}

function scanAicxShadows(installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll("aicx")));
function scanBinaryShadows(binaryName, installedPath, targetVersion) {
const pathBinaries = Array.from(new Set(whichAll(binaryName)));
if (pathBinaries.length === 0) return;

console.warn("[AICX npm] Existing aicx binaries on PATH:");
console.warn(`[AICX npm] Existing ${binaryName} binaries on PATH:`);
for (const path of pathBinaries) {
const version = commandOutput(path, ["--version"]) || "unknown";
console.warn(` ${path} -> ${version}`);
}

const resolved = pathBinaries[0];
if (resolved && resolved !== installedPath) {
console.warn("[AICX npm] WARNING: PATH may resolve to a different aicx than this npm package.");
console.warn(`[AICX npm] WARNING: PATH may resolve to a different ${binaryName} than this npm package.`);
console.warn(` npm package binary: ${installedPath} -> ${targetVersion}`);
console.warn(` PATH resolves to: ${resolved}`);
console.warn(" Set AICX_NPM_REPLACE_LOCAL=1 to remove older/equal ~/.local/bin or cargo-bin shadows during npm install.");
}
}

function scanAicxShadows(installedAicxPath, installedMcpPath, targetVersion) {
scanBinaryShadows("aicx", installedAicxPath, targetVersion);
scanBinaryShadows("aicx-mcp", installedMcpPath, targetVersion);
if (envFlag("AICX_NPM_REPLACE_LOCAL")) {
cleanupShadowDir("local-bin", targetVersion);
cleanupShadowDir("cargo-bin", targetVersion);
Expand Down Expand Up @@ -232,7 +236,7 @@ async function install() {
const targetAicxMcp = join(__dirname, `aicx-mcp${exe}`);
if (existsSync(targetAicx) && existsSync(targetAicxMcp)) {
console.log(`Binaries already exist at ${__dirname}`);
scanAicxShadows(targetAicx, VERSION);
scanAicxShadows(targetAicx, targetAicxMcp, VERSION);
return;
}

Expand Down Expand Up @@ -264,7 +268,7 @@ async function install() {
rmSync(tempDir, { recursive: true, force: true });

console.log(`Successfully installed aicx binaries to ${__dirname}`);
scanAicxShadows(targetAicx, VERSION);
scanAicxShadows(targetAicx, targetAicxMcp, VERSION);
} catch (error) {
rmSync(tempDir, { recursive: true, force: true });
console.error(`
Expand Down
18 changes: 13 additions & 5 deletions docs/ARCHITECTURE.md
Original file line number Diff line number Diff line change
Expand Up @@ -130,15 +130,23 @@ Frontmatter is not just telemetry — it is part of the steering and selective r

## MCP Surface (`src/mcp.rs`)

The MCP server exposes four tools via stdio and streamable HTTP transports:
The MCP server exposes six tools via stdio and streamable HTTP transports:

- `aicx_search` — canonical-store filesystem fuzzy search with quality scoring and `oracle_status`; this is not semantic retrieval and is not safe for Loctree scope narrowing until callers read the canonical chunks
- `aicx_read` — read one canonical chunk by path, file name, or compact reference; this is the direct re-entry step after search, refs, steer, or dashboard discovery
- `aicx_rank` — rank chunks by signal density for a project as compact JSON
- `aicx_steer` — retrieve chunks by steering metadata (run_id, prompt_id, agent, kind, project, date) using sidecar data; returns `oracle_status` for the rebuildable metadata index and is safe for Loctree metadata narrowing only when source paths verify
- `aicx_search` — semantic-first search over the canonical corpus. Ready indexes return `hybrid_rrf` oracle status and are safe for Loctree scope narrowing. Missing semantic preconditions degrade to filesystem fuzzy with an explicit `semantic_fallback` payload; callers that need fail-fast semantics pass `strict_semantic = true`.
- `aicx_read` — read one canonical chunk by path, file name, or compact reference; this is the direct re-entry step after search, refs, steer, or dashboard discovery.
- `aicx_rank` — rank chunks by signal density for a project as compact JSON.
- `aicx_steer` — retrieve chunks by steering metadata (run_id, prompt_id, agent, kind, project, date) using sidecar data; returns `oracle_status` for the rebuildable metadata index and is safe for Loctree metadata narrowing only when source paths verify.
- `aicx_intents` — extract intent/outcome/decision/task records from canonical chunks.
- `aicx_index_status` — report the sessions -> chunks -> semantic-index pipeline for a project bucket, including readiness, backend, row count, and artifact paths.

Recency filtering in `aicx_search` and `aicx_steer` uses canonical chunk dates from the store layout, not filesystem `mtime` accidents.

The streamable HTTP transport binds to `127.0.0.1` by default and keeps rmcp's
loopback-only `Host` validation. Operators can explicitly pass `--host <IP>` to
listen on another interface and `--allowed-host <HOST>` for each remote
hostname/IP clients will use. `--allow-any-host` disables that DNS-rebinding
guard and is intended only for trusted networks.

## Security Model (Pragmatic)

Two mechanisms protect your machine and your data:
Expand Down
26 changes: 25 additions & 1 deletion docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -699,7 +699,10 @@ aicx state --info
Run `aicx` as an MCP server (stdio or streamable HTTP transport).

Exposes search, read, steer, intents, and rank tools over MCP for agent retrieval.
`aicx_search` is semantic and fails fast when the index is not ready.
`aicx_search` is semantic-first and uses the same filtered retrieval primitive
as CLI `aicx search`. When the semantic index or embedder is not ready it
returns filesystem-fuzzy results with an explicit `semantic_fallback` payload;
pass `strict_semantic = true` to get fail-fast semantic errors.
`aicx_steer`, `aicx_intents`, and `aicx_rank` query the canonical corpus on disk
and return grounded source paths or chunk references.
`aicx_read` pulls the actual chunk content by path, file name, or compact
Expand All @@ -711,14 +714,35 @@ aicx serve [OPTIONS]

Options:
- `--transport <stdio|http>` transport (default: `stdio`; legacy alias `sse` is still accepted)
- `--host <HOST>` streamable HTTP bind host (default: `127.0.0.1`)
- `--port <PORT>` streamable HTTP port (default: `8044`)
- `--allowed-host <HOST>` allowed inbound HTTP `Host` header for streamable HTTP clients; repeat for remote hostnames/IPs
- `--allow-any-host` disable HTTP `Host` validation; only use on trusted networks
- `--auth-token <TOKEN>` explicit Bearer token for HTTP transport
- `--no-require-auth` disable HTTP auth for local debugging only

Example:

```bash
aicx serve --transport http --port 8044
aicx serve --transport http --host 0.0.0.0 --allowed-host mcp.example.internal --port 8044
aicx-mcp --transport http --host 127.0.0.1 --port 8044
```

### HTTP auth token resolution

The HTTP transport resolves a single Bearer token from one canonical cascade
(first match wins):

1. `--auth-token <TOKEN>` (explicit CLI override)
2. `AICX_HTTP_AUTH_TOKEN` environment variable
3. `<AICX_HOME>/auth-token` file — honors `$AICX_HOME`; defaults to `~/.aicx/auth-token`
4. otherwise a token is generated and persisted to that file (mode `0600` on Unix)

`--no-require-auth` skips the cascade entirely and serves without auth (local
debugging only). Run `aicx doctor` to see which source is active — it reports
the token *source*, never the token value.

## `aicx init` (Retired)

`aicx init` has been retired. Context initialisation is now handled by `/vc-init` inside Claude Code.
Expand Down
18 changes: 14 additions & 4 deletions docs/ORACLE_CORPUS.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,10 +17,20 @@ embedding surface is a derived view that must be rebuildable from that corpus.

## Operator Surfaces

- `aicx search --json` and MCP `aicx_search` return `oracle_status.backend =
filesystem_fuzzy`, `index_kind = none`, and a non-null `fallback_reason`.
Treat these results as routing evidence only. Loctree must read the canonical
chunks before trusting scope.
- `aicx search --json` and MCP `aicx_search` are semantic-first. When the
semantic index and embedder are ready they return `oracle_status.backend =
hybrid_rrf`, `index_kind = onion_content`, and `loctree_scope_safe = true`.
This is the preferred retrieval surface for humans and agents. Hybrid results
also carry an `index_snapshot` payload (`freshness_verified = false`,
`source_chunks = N`): the result reflects the committed index manifest, not a
live freshness check. To confirm there are no pending (un-embedded) chunks,
run `aicx index status` (or `aicx doctor`, check `index_freshness`) — the
search hot path deliberately does not pay for that scan.
- If semantic preconditions are missing, `aicx search --json` and MCP
`aicx_search` degrade to canonical-store filesystem fuzzy search and include
an explicit `semantic_fallback` payload. Treat fallback results as routing
evidence only; Loctree must read the canonical chunks before trusting scope.
MCP clients that need fail-fast behavior can pass `strict_semantic = true`.
- `aicx intents --emit json` and MCP `aicx_intents` return
`backend = canonical_corpus` and `index_kind = canonical_chunks`. This is
canonical intent evidence, not semantic similarity.
Expand Down
61 changes: 54 additions & 7 deletions docs/install-paths.md
Original file line number Diff line number Diff line change
@@ -1,7 +1,7 @@
# AICX Install Paths

This document maps the supported binary install channels and the shadow checks
that keep `PATH` from resolving an older `aicx`.
that keep `PATH` from resolving older `aicx` or `aicx-mcp` binaries.

## Install Surface

Expand Down Expand Up @@ -37,15 +37,16 @@ npm @loctree/aicx

## Pre-install Scan

Shell installers run a preflight inventory:
Shell installers run a preflight inventory for both binaries:

```bash
which -a aicx
which -a aicx-mcp
```

Each path is probed with `--version` when executable. If multiple binaries are
visible, or the current `PATH` winner is not the target install path, interactive
installs ask for confirmation. Automation can set:
visible, or the current `PATH` winner is not the target install path,
interactive installs ask for confirmation. Automation can set:

```bash
AICX_INSTALL_FORCE=1 bash install.sh
Expand All @@ -72,17 +73,63 @@ because the installer cannot prove that removal is safe.

## Post-install Sanity

After shell installs, `install.sh` compares:
After shell installs, `install.sh` compares both runtime entry points:

```bash
<target>/aicx --version
command -v aicx
aicx --version
<target>/aicx-mcp --version
command -v aicx-mcp
aicx-mcp --version
```

If the versions or paths differ, the installer prints a warning with the target
path, the `PATH`-resolved path, and all other `which -a aicx` entries. The fix is
to move the target directory earlier in `PATH` or remove the older channel.
path, the `PATH`-resolved path, and all other `which -a` entries for that
binary. The fix is to move the target directory earlier in `PATH` or remove the
older channel.

## MCP Runtime Drift

The CLI and MCP server are shipped as one versioned pair. Any long-running MCP
service, launchd unit, systemd unit, or hand-written wrapper must point at the
same installed `aicx-mcp` that the installer verifies on `PATH`. A copied
service-local binary can keep answering MCP health checks while running older
search behavior.

`aicx doctor` reports this drift class directly: the `binary_pair` check
compares the running CLI against the `aicx-mcp` resolved on `PATH`, `aicx_home`
shows which `AICX_HOME` was resolved (and whether `store/` + `indexed/` live
there), and `http_auth_token` shows where the HTTP token resolves from without
printing its value.

Minimum parity smoke after updating a service:

```bash
aicx --version
aicx-mcp --version
aicx-mcp --help | grep -- "--host"
aicx doctor --format json # inspect binary_pair / aicx_home / http_auth_token
aicx index status --json
aicx search "operator decision" --json --limit 1
```

For streamable HTTP MCP, also call `aicx_index_status` and `aicx_search` through
the client transport and compare the reported `indexed_count`, `readiness`, and
`oracle_status.backend` with the CLI output. `aicx_search` should report
`hybrid_rrf` when the same CLI search does; if it reports `content_semantic` or
silent zero results while CLI returns `hybrid_rrf`, the service is stale or
running a different binary/config.

Remote MCP services that bind outside loopback also need HTTP `Host` validation
configured for the client-facing hostname/IP:

```bash
aicx-mcp --transport http --host 0.0.0.0 --allowed-host mcp.example.internal --port 8044
```

Without a matching `--allowed-host`, rmcp rejects the request before MCP tools
run and the client sees `403 Forbidden: Host header is not allowed`.

## npm Opt-in Replacement

Expand Down
Loading