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
13 changes: 11 additions & 2 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,12 @@
> coding agents, and a **local-first visual map** for humans. No LLM in the engine,
> no network, your code never leaves your machine.

<p align="center">
<img src="docs/images/viewer.png" alt="OpenVisio viewer — the Atlas view of a large codebase, with files and symbols as a constellation linked by amber import and call edges" width="100%">
<br>
<em>The viewer's <strong>Atlas</strong> view — every file and symbol as a constellation linked by imports, definitions, and calls (here: a 93K-file repo).</em>
</p>

OpenVisio parses any repository with tree-sitter into a symbol + import graph,
ranks it with PageRank, and serves it two ways:

Expand Down Expand Up @@ -68,8 +74,11 @@ openvisio view ../other # …or any other local repo
```

The viewer ships in the `openvisio-viewer` package: the same React/Three.js
Atlas + City views, as a self-contained static bundle. Toggle between them, click
to focus a file, and re-index any other local repo from the path box. Nothing
Atlas + City views, as a self-contained static bundle. It opens on the **Atlas**
by default; switch to **City** with the view toggle (top-right). Click **Index**
to point it at any local repo — browse the filesystem in the built-in folder
picker (git repos are flagged) or type a path — and a staged progress loader runs
while the deterministic engine indexes. Click any node to focus it. Nothing
leaves your machine.

**Watch your agent think.** `view` defaults to the spotlight port (7077), so it
Expand Down
2 changes: 1 addition & 1 deletion core/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "@openvisio/core",
"version": "0.1.0",
"version": "0.2.0",
"description": "Deterministic, local-first code-graph engine for OpenVisio: tree-sitter symbol/import extraction, PageRank ranking, token-budgeted skeletons and slices. No LLM, no network.",
"type": "module",
"license": "MIT",
Expand Down
150 changes: 119 additions & 31 deletions core/src/build.ts
Original file line number Diff line number Diff line change
Expand Up @@ -35,6 +35,47 @@ interface AliasRule {
targets: string[]
}

/**
* Parse a tsconfig/jsconfig body into alias rules. `dirRel` is the config's own
* directory, repo-relative ('' for the root) — it's prefixed onto `baseUrl` so
* the rule targets (e.g. `src/*`) resolve repo-relative even for a config that
* lives in a sub-package (e.g. `viewer/tsconfig.json` → baseUrl `viewer`). This
* is what makes `@/…` imports resolve across a monorepo with no root tsconfig.
*/
function parseTsAliases(raw: string, dirRel: string): TsAliases | null {
let config: any
try {
config = parseJsonc(raw)
} catch {
return null
}
const co = config?.compilerOptions ?? {}
const baseUrlRaw: string = typeof co.baseUrl === 'string' ? co.baseUrl : '.'
const baseRel = posix.normalize(baseUrlRaw).replace(/^\.\/?/, '').replace(/\/$/, '')
let baseUrl = posix.join(dirRel || '.', baseRel === '.' ? '' : baseRel)
if (baseUrl === '.') baseUrl = ''
const rules: AliasRule[] = []
const paths = co.paths
if (paths && typeof paths === 'object') {
for (const key of Object.keys(paths)) {
const star = key.indexOf('*')
const targets = (Array.isArray(paths[key]) ? paths[key] : []).filter(
(t: unknown): t is string => typeof t === 'string',
)
if (targets.length === 0) continue
if (star === -1) {
rules.push({ prefix: key, suffix: '\0exact', targets })
} else {
rules.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), targets })
}
}
}
const excludes = (Array.isArray(config?.exclude) ? config.exclude : []).filter(
(e: unknown): e is string => typeof e === 'string',
)
return { baseUrl, rules, excludes }
}

function loadTsAliases(absRoot: string): TsAliases {
const empty: TsAliases = { baseUrl: '', rules: [], excludes: [] }
for (const name of ['tsconfig.json', 'jsconfig.json']) {
Expand All @@ -44,35 +85,7 @@ function loadTsAliases(absRoot: string): TsAliases {
} catch {
continue
}
let config: any
try {
config = parseJsonc(raw)
} catch {
return empty
}
const co = config?.compilerOptions ?? {}
const baseUrlRaw: string = typeof co.baseUrl === 'string' ? co.baseUrl : '.'
const baseUrl = posix.normalize(baseUrlRaw).replace(/^\.\/?/, '').replace(/\/$/, '')
const rules: AliasRule[] = []
const paths = co.paths
if (paths && typeof paths === 'object') {
for (const key of Object.keys(paths)) {
const star = key.indexOf('*')
const targets = (Array.isArray(paths[key]) ? paths[key] : []).filter(
(t: unknown): t is string => typeof t === 'string',
)
if (targets.length === 0) continue
if (star === -1) {
rules.push({ prefix: key, suffix: '\0exact', targets })
} else {
rules.push({ prefix: key.slice(0, star), suffix: key.slice(star + 1), targets })
}
}
}
const excludes = (Array.isArray(config?.exclude) ? config.exclude : []).filter(
(e: unknown): e is string => typeof e === 'string',
)
return { baseUrl: baseUrl === '.' ? '' : baseUrl, rules, excludes }
return parseTsAliases(raw, '') ?? empty
}
return empty
}
Expand Down Expand Up @@ -225,10 +238,35 @@ export async function assembleGraph(

console.error(`[build] resolving imports across ${files.length} files...`)
const bySet = new Set(fileIdByPath.keys())
const aliases = ctx.aliases ?? loadTsAliases(absRoot)

// Per-package alias resolution. A monorepo can have many tsconfig/jsconfig
// files (one per sub-package, often with no root config at all), each mapping
// `@/*` to its own `src/*`. Discover them all from the scanned set and resolve
// every file's imports against its NEAREST enclosing config — otherwise all
// `@/…` imports go unresolved and the import graph collapses to relative-only.
const aliasEntries: { dir: string; aliases: TsAliases }[] = []
for (const sf of scanned) {
const base = posix.basename(sf.relPath)
if (base !== 'tsconfig.json' && base !== 'jsconfig.json') continue
const dirRaw = posix.dirname(sf.relPath)
const dir = dirRaw === '.' ? '' : dirRaw
const parsed = parseTsAliases(sf.content, dir)
if (parsed) aliasEntries.push({ dir, aliases: parsed })
}
// Longest dir first so the nearest config wins the prefix match.
aliasEntries.sort((a, b) => b.dir.length - a.dir.length)
const rootAliases = ctx.aliases ?? loadTsAliases(absRoot)
const aliasesFor = (relPath: string): TsAliases => {
for (const e of aliasEntries) {
if (e.dir === '' || relPath === e.dir || relPath.startsWith(e.dir + '/')) return e.aliases
}
return rootAliases
}

const edgeWeights = new Map<string, number>()
for (const file of files) {
const raws = rawImportsByFile.get(file.id) ?? []
const aliases = aliasesFor(file.path)
for (const raw of raws) {
const targetPath = resolveImport(file, raw.specifier, bySet, aliases)
if (targetPath == null) continue
Expand Down Expand Up @@ -357,6 +395,22 @@ export interface IndexChanges {
changed: string[]
}

// Persistent-cache schema version. The LMDB cache stores parse results (keyed by
// content SHA) and file IDs (keyed by path). Those are only valid for the engine
// that wrote them — when the parser's output shape changes (new symbol fields,
// import/call extraction, id allocation), a same-SHA entry from an OLDER engine
// would be served verbatim, silently dropping imports/edges or aliasing symbols
// to the wrong file. BUMP THIS whenever parse output or id allocation changes;
// the Indexer wipes a cache whose stored version doesn't match.
export const CACHE_VERSION = 2
const CACHE_VERSION_KEY = 'meta:cacheVersion'

function bytesEqual(a: Uint8Array, b: Uint8Array): boolean {
if (a.length !== b.length) return false
for (let i = 0; i < a.length; i++) if (a[i] !== b[i]) return false
return true
}

function encodeU32(n: number): Uint8Array {
const buf = new Uint8Array(4)
const dv = new DataView(buf.buffer)
Expand Down Expand Up @@ -390,10 +444,27 @@ export class Indexer {
if (this.dbPath && !this.store) {
const { LmdbStore } = await import('./stores/lmdb.js')
this.store = new LmdbStore(this.dbPath)
this.ensureCacheVersion(this.store)
}
return (await this.run()).graph
}

/**
* Wipe the cache if it was written by a different engine version. A same-SHA
* parse result (or persisted file id) from an older engine is NOT safe to
* reuse — it can drop import/call edges or alias symbols to the wrong file —
* so on a version mismatch we clear everything and start the cache fresh.
*/
private ensureCacheVersion(store: Store): void {
const want = new TextEncoder().encode(String(CACHE_VERSION))
const have = store.get(CACHE_VERSION_KEY)
if (!have || !bytesEqual(have, want)) {
store.clear()
store.set(CACHE_VERSION_KEY, want)
store.sync()
}
}

async reindex(): Promise<{ graph: CodeGraph; changes: IndexChanges }> {
return this.run()
}
Expand All @@ -414,8 +485,25 @@ export class Indexer {
if (this.store && this.idByPath.size === 0) {
for (const sf of scanned) {
const idBuf = this.store.get(`id:${sf.relPath}`)
if (idBuf) this.idByPath.set(sf.relPath, decodeU32(idBuf))
// Only a well-formed 4-byte id is trustworthy; a truncated/corrupt
// value from a legacy cache must never reach decodeU32 (it throws) or
// seed a bogus id.
if (idBuf && idBuf.length === 4) this.idByPath.set(sf.relPath, decodeU32(idBuf))
}
// Defensive: a corrupt/legacy store can hand back the SAME id for two
// distinct paths, which would alias one file's symbols onto another file
// (e.g. a function anchored to a tsconfig). If the restored ids aren't
// unique, discard them all and reallocate fresh in scan order.
const seen = new Set<number>()
let collision = false
for (const id of this.idByPath.values()) {
if (seen.has(id)) {
collision = true
break
}
seen.add(id)
}
if (collision) this.idByPath.clear()
}

const changes: IndexChanges = { added: [], removed: [], changed: [] }
Expand Down
2 changes: 2 additions & 0 deletions core/src/store.ts
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,8 @@ export interface Store {
get(key: string): Uint8Array | null
set(key: string, value: Uint8Array): void
delete(key: string): void
/** Drop every entry — used to invalidate a cache built by an older engine. */
clear(): void
sync(): void
close(): void
}
4 changes: 4 additions & 0 deletions core/src/stores/lmdb.ts
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,10 @@ export class LmdbStore implements Store {
this.db.removeSync(key)
}

clear(): void {
this.db.clearSync()
}

sync(): void {
this.db.transactionSync(() => {})
}
Expand Down
Binary file modified docs/images/viewer.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
17 changes: 13 additions & 4 deletions mcp/README.md
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,12 @@ network, your code never leaves your machine.**
> [viewer](https://github.com/syntaxpriest/openvisio-oss) is for the human. They
> share the same index.

<p align="center">
<img src="https://raw.githubusercontent.com/syntaxpriest/openvisio-oss/main/docs/images/viewer.png" alt="OpenVisio viewer — the Atlas view of a large codebase, with files and symbols linked by amber import and call edges" width="100%">
<br>
<em>The viewer's <strong>Atlas</strong> view (<code>openvisio view</code>) — every file and symbol as a constellation linked by imports, definitions, and calls.</em>
</p>

---

## Why
Expand Down Expand Up @@ -129,10 +135,13 @@ openvisio export [path] [--out=.openvisio/graph.json] # emit the graph
package) on `127.0.0.1`, then opens your browser. City is a 3D treemap
(districts = folders, buildings = files, sized by LOC, colored by language);
Atlas is a file/symbol constellation linked by imports, definitions, and calls.
Toggle between them, click to focus, and re-index any other local repo from the
path box. `--no-open` just prints the URL. It defaults to the spotlight port
(7077) and acts as the highlight hub: with `view` running, start your agent
with `openvisio mcp . --spotlight` and its tool calls focus the graph live.
It opens on the Atlas by default; switch to City with the view toggle. Click
**Index** to browse the filesystem (git repos are flagged) or type a path and
index any other local repo, with a staged progress loader while it builds, then
click to focus a node. `--no-open` just prints the URL. It defaults to the
spotlight port (7077) and acts as the highlight hub: with `view` running, start
your agent with `openvisio mcp . --spotlight` and its tool calls focus the graph
live.

---

Expand Down
4 changes: 2 additions & 2 deletions mcp/package.json
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
{
"name": "openvisio",
"version": "0.1.5",
"version": "0.2.0",
"description": "See any codebase as a graph. MCP server + CLI that serves coding agents (Claude Code, Codex, Cursor) a token-cheap, ranked, graph-native query surface over a local repo — agents query structure instead of crawling files. Local-first, read-only, no network.",
"type": "module",
"license": "MIT",
Expand Down Expand Up @@ -45,7 +45,7 @@
"dependencies": {
"@modelcontextprotocol/sdk": "^1.29.0",
"lmdb": "^3.2.6",
"openvisio-viewer": "^0.1.0",
"openvisio-viewer": "^0.2.0",
"tree-sitter-wasms": "^0.1.13",
"web-tree-sitter": "~0.25.10",
"zod": "^4.4.3"
Expand Down
Loading
Loading