Skip to content
Open
Show file tree
Hide file tree
Changes from 15 commits
Commits
Show all changes
16 commits
Select commit Hold shift + click to select a range
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
20 changes: 10 additions & 10 deletions .github/workflows/release.yml
Original file line number Diff line number Diff line change
Expand Up @@ -89,8 +89,8 @@ jobs:
run: |
STAGING="ix-${{ steps.version.outputs.version }}-linux-amd64"
mkdir -p "$STAGING/cli" "$STAGING/core-ingestion" "$STAGING/compass"
cp -r ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -r core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
cp -rL ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -rL core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
[ -d system-compass/dist ] && cp -r system-compass/dist/* "$STAGING/compass/" || true
cat > "$STAGING/ix" <<'WRAPPER'
#!/bin/sh
Expand All @@ -105,8 +105,8 @@ jobs:
run: |
STAGING="ix-${{ steps.version.outputs.version }}-linux-arm64"
mkdir -p "$STAGING/cli" "$STAGING/core-ingestion" "$STAGING/compass"
cp -r ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -r core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
cp -rL ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -rL core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
[ -d system-compass/dist ] && cp -r system-compass/dist/* "$STAGING/compass/" || true
cat > "$STAGING/ix" <<'WRAPPER'
#!/bin/sh
Expand All @@ -121,8 +121,8 @@ jobs:
run: |
STAGING="ix-${{ steps.version.outputs.version }}-darwin-amd64"
mkdir -p "$STAGING/cli" "$STAGING/core-ingestion" "$STAGING/compass"
cp -r ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -r core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
cp -rL ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -rL core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
[ -d system-compass/dist ] && cp -r system-compass/dist/* "$STAGING/compass/" || true
cat > "$STAGING/ix" <<'WRAPPER'
#!/bin/sh
Expand All @@ -137,8 +137,8 @@ jobs:
run: |
STAGING="ix-${{ steps.version.outputs.version }}-darwin-arm64"
mkdir -p "$STAGING/cli" "$STAGING/core-ingestion" "$STAGING/compass"
cp -r ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -r core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
cp -rL ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -rL core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
[ -d system-compass/dist ] && cp -r system-compass/dist/* "$STAGING/compass/" || true
cat > "$STAGING/ix" <<'WRAPPER'
#!/bin/sh
Expand All @@ -153,8 +153,8 @@ jobs:
run: |
STAGING="ix-${{ steps.version.outputs.version }}-windows-amd64"
mkdir -p "$STAGING/cli" "$STAGING/core-ingestion" "$STAGING/compass"
cp -r ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -r core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
cp -rL ix-cli/dist ix-cli/node_modules ix-cli/package.json "$STAGING/cli/"
cp -rL core-ingestion/dist core-ingestion/node_modules core-ingestion/package.json "$STAGING/core-ingestion/"
[ -d system-compass/dist ] && cp -r system-compass/dist/* "$STAGING/compass/" || true
cat > "$STAGING/ix.cmd" <<'WRAPPER'
@echo off
Expand Down
4 changes: 3 additions & 1 deletion .gitignore
Original file line number Diff line number Diff line change
Expand Up @@ -11,4 +11,6 @@ core-ingestion/node_modules/*
core-ingestion/dist/
ix-cli/dist/
.vscode/
.mcp.json
.mcp.json
# Ix agent worktrees
.ix-worktrees/
1 change: 1 addition & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -105,6 +105,7 @@ gemini extensions install https://github.com/ix-infrastructure/ix-gemini-plugin
## Requirements

- macOS, Linux, or Windows
- Node.js 20 or newer
- Git installed
- Docker (for full functionality)

Expand Down
6 changes: 6 additions & 0 deletions core-ingestion/src/types.ts
Original file line number Diff line number Diff line change
@@ -1,8 +1,14 @@
export interface PatchSource {
// uri is the provenance source URI. In the client-agnostic backend design it
// is a workspace-relative path (POSIX separators), not an absolute host path.
uri: string;
sourceHash?: string;
extractor: string;
sourceType: string;
// workspaceId uniquely identifies the workspace whose files produced this
// patch. Derived client-side (SHA-256 of workspace root abs path). Backend
// stores it as an opaque attribute.
workspaceId?: string;
}

export interface PatchOp {
Expand Down
54 changes: 49 additions & 5 deletions docker-compose.standalone.yml
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,13 @@
# Location when installed: ~/.ix/backend/docker-compose.yml
#
# Uses published Docker images — no local build step required.
#
# NOTE: the previous HOME bind mount (${IX_HOST_MOUNT_ROOT:-$HOME}) has been
# removed. The backend no longer reads host files — staleness detection is now
# a pure graph comparison (see ix-memory-layer StalenessDetector) and
# provenance.source_uri is workspace-relative, so the container never needs
# visibility into the client's filesystem. This closes the privacy hole where
# the entire user HOME directory was bind-mounted into the backend container.

services:
arangodb:
Expand All @@ -14,6 +21,7 @@ services:
- "127.0.0.1:8529:8529"
environment:
ARANGO_NO_AUTH: "1"
command: ["arangod", "--server.endpoint", "tcp://0.0.0.0:8529", "--experimental-vector-index"]
volumes:
- arangodb-data:/var/lib/arangodb3
healthcheck:
Expand All @@ -24,23 +32,58 @@ services:
retries: 15
restart: unless-stopped

ollama:
image: ollama/ollama:latest
networks:
- backend
ports:
- "127.0.0.1:11434:11434"
volumes:
- ollama-data:/root/.ollama
restart: unless-stopped
profiles:
- semantic

semantic-extraction:
image: ghcr.io/ix-infrastructure/ix-semantic-extraction:latest
networks:
- backend
ports:
- "127.0.0.1:11400:11400"
environment:
OLLAMA_BASE_URL: http://ollama:11434
IX_EXTRACTION_MODEL: qwen2.5vl:7b
IX_EMBEDDING_MODEL: "nomic-embed-text:v2-moe"
IX_AUTO_PULL: "true"
PORT: "11400"
depends_on:
- ollama
healthcheck:
test: ["CMD", "curl", "-sf", "http://localhost:11400/v1/health"]
interval: 10s
timeout: 5s
start_period: 120s
retries: 10
restart: unless-stopped
profiles:
- semantic

memory-layer:
image: ghcr.io/ix-infrastructure/ix-memory-layer:latest
networks:
- backend
ports:
- "127.0.0.1:8090:8090"
volumes:
- type: bind
source: ${IX_HOST_MOUNT_ROOT:-$HOME}
target: ${IX_CONTAINER_MOUNT_ROOT:-$HOME}
read_only: true
# No host bind mount: the backend is client-agnostic and never reads host
# files. If you are on an old backend image that still expects the HOME
# bind mount, upgrade to the client-agnostic image (schema_version >= 2).
environment:
ARANGO_HOST: arangodb
ARANGO_PORT: "8529"
ARANGO_DATABASE: ix_memory
ARANGO_USER: root
ARANGO_PASSWORD: ""
IX_EXTRACTION_URL: http://semantic-extraction:11400
PORT: "8090"
depends_on:
arangodb:
Expand All @@ -55,6 +98,7 @@ services:

volumes:
arangodb-data:
ollama-data:

networks:
backend:
3 changes: 3 additions & 0 deletions ix-cli/package.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,9 @@
"bin": {
"ix": "dist/cli/main.js"
},
"engines": {
"node": ">=20.0.0"
},
"scripts": {
"build": "node scripts/build-core-ingestion.mjs && node -e \"require('fs').rmSync('dist',{recursive:true,force:true})\" && tsc",
"test": "node scripts/build-core-ingestion.mjs && vitest run --exclude test/parser.test.ts && node test/parser.smoke.mjs",
Expand Down
63 changes: 60 additions & 3 deletions ix-cli/src/cli/__tests__/github-transform.test.ts
Original file line number Diff line number Diff line change
Expand Up @@ -11,7 +11,7 @@ describe("GitHub transform", () => {
expect(id1).toMatch(/^[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}$/);
});

it("transforms an issue into UpsertNode ops", async () => {
it("transforms an issue into UpsertNode with intent kind and github attrs", async () => {
const { transformIssue } = await import("../github/transform.js");
const ops = transformIssue(
{ owner: "acme", repo: "app" },
Expand All @@ -27,9 +27,27 @@ describe("GitHub transform", () => {
expect(upsert).toBeDefined();
expect(upsert!.kind).toBe("intent");
expect(upsert!.name).toBe("Fix login bug");
expect((upsert as any).attrs.source).toBe("github");
expect((upsert as any).attrs.github_type).toBe("issue");
expect((upsert as any).attrs.is_bug).toBe(true);
});

it("transforms a PR into UpsertNode ops with decision kind", async () => {
it("marks non-bug issues with is_bug false", async () => {
const { transformIssue } = await import("../github/transform.js");
const ops = transformIssue(
{ owner: "acme", repo: "app" },
{
number: 43, title: "Add feature", body: "New feature request",
state: "open", user: { login: "bob" }, labels: [{ name: "enhancement" }],
created_at: "2026-01-01T00:00:00Z", updated_at: "2026-01-02T00:00:00Z",
html_url: "https://github.com/acme/app/issues/43", comments: 0,
}
);
const upsert = ops.find((op: any) => op.type === "UpsertNode") as any;
expect(upsert.attrs.is_bug).toBe(false);
});

it("transforms a PR into UpsertNode with decision kind and github attrs", async () => {
const { transformPR } = await import("../github/transform.js");
const ops = transformPR(
{ owner: "acme", repo: "app" },
Expand All @@ -45,9 +63,33 @@ describe("GitHub transform", () => {
expect(upsert).toBeDefined();
expect(upsert!.kind).toBe("decision");
expect(upsert!.name).toBe("Add auth flow");
expect((upsert as any).attrs.source).toBe("github");
expect((upsert as any).attrs.github_type).toBe("pull_request");
});

it("creates RESOLVES edges when PR body references issues", async () => {
const { transformPR, deterministicId } = await import("../github/transform.js");
const ops = transformPR(
{ owner: "acme", repo: "app" },
{
number: 11, title: "Fix auth", body: "Fixes #42 and closes #43",
state: "closed", merged_at: "2026-01-05T00:00:00Z",
user: { login: "bob" }, base: { ref: "main" }, head: { ref: "fix/auth" },
created_at: "2026-01-03T00:00:00Z", updated_at: "2026-01-05T00:00:00Z",
html_url: "https://github.com/acme/app/pull/11", changed_files: 2,
}
);
const edges = ops.filter((op: any) => op.type === "UpsertEdge" && op.predicate === "RESOLVES");
expect(edges.length).toBe(2);
// Verify edge destinations match the expected issue node IDs
const issue42Id = deterministicId("github://acme/app/issues/42");
const issue43Id = deterministicId("github://acme/app/issues/43");
const dstIds = edges.map((e: any) => e.dst);
expect(dstIds).toContain(issue42Id);
expect(dstIds).toContain(issue43Id);
});

it("transforms a commit into UpsertNode ops with doc kind", async () => {
it("transforms a commit into UpsertNode with doc kind and github attrs", async () => {
const { transformCommit } = await import("../github/transform.js");
const ops = transformCommit(
{ owner: "acme", repo: "app" },
Expand All @@ -62,5 +104,20 @@ describe("GitHub transform", () => {
expect(upsert).toBeDefined();
expect(upsert!.kind).toBe("doc");
expect(upsert!.name).toContain("fix: resolve null pointer");
expect((upsert as any).attrs.source).toBe("github");
expect((upsert as any).attrs.github_type).toBe("commit");
});

it("parseFixesRefs extracts issue numbers from various patterns", async () => {
const { parseFixesRefs } = await import("../github/transform.js");
expect(parseFixesRefs("Fixes #42")).toEqual([42]);
expect(parseFixesRefs("closes #10 and fixes #20")).toEqual([10, 20]);
expect(parseFixesRefs("Resolved #5")).toEqual([5]);
expect(parseFixesRefs("Fixed #1, closes #2, resolves #3")).toEqual([1, 2, 3]);
expect(parseFixesRefs("No references here")).toEqual([]);
expect(parseFixesRefs(null)).toEqual([]);
expect(parseFixesRefs(undefined)).toEqual([]);
// Deduplicates
expect(parseFixesRefs("Fixes #42 and also fixes #42")).toEqual([42]);
});
});
Loading
Loading