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
2 changes: 1 addition & 1 deletion CLAUDE.md
Original file line number Diff line number Diff line change
Expand Up @@ -18,7 +18,7 @@ python3 install.py # --cli-only, --dev, --prefix, --verify, --uninstal
# Pipe a single command (non-interactive; cli.py detects a non-tty stdin)
echo '/search "q2 plan"' | python3 -m glean_code

# Run the full test suite (1,109 tests, stdlib unittest — works with or without pytest)
# Run the full test suite (1,148 tests, stdlib unittest — works with or without pytest)
python3 -m pytest tests/
python3 -m unittest discover tests/

Expand Down
6 changes: 5 additions & 1 deletion README.md
Original file line number Diff line number Diff line change
Expand Up @@ -68,13 +68,16 @@ python3 install.py && glean # installed, with a macOS Spotlight app
| `/index.bulk-documents --path ./docs/ --datasource custom1 --object-type Article --dry-run` | Index a folder, inspecting the payload first |
| `/metadata.attach --doc-id <id> --group tickets --values owner=alice` | Enrich docs without re-uploading them |
| `/personal index ~/Documents --label docs` | Index your own files, locally |
| `/graph "quarterly planning" --html graph.html` | Graph how that content relates — documents, authors, sources |
| `/flow show` | Draw the investigations you ran |

<table>
<tr><td width="50%" valign="top">

### 🔎 Search &amp; chat
Search, autocomplete, recommendations, threaded chat, summarize, answers, feedback — plus docs, people, entities, announcements, collections, pins, Go Links, and verification.
`/graph` turns any result set into a knowledge graph of documents, authors and sources.
[Knowledge graph](docs/GRAPH.md)

</td><td width="50%" valign="top">

Expand Down Expand Up @@ -172,11 +175,12 @@ The full Glean Code REPL — slash commands, status bar, mock/live switching, se
| 📊 **[Insights](docs/INSIGHTS.md)** | Flags, output, and CSV export |
| 🧪 **[Mock corpus](docs/MOCK_CORPUS.md)** | The offline corpus — inventory, ranking, bring-your-own format |
| 🏠 **[Local indexing](docs/LOCAL_INDEXING.md)** · **[Personal](docs/PERSONAL.md)** | Index your own folders, search them, keep them current |
| 🕸️ **[Knowledge graph](docs/GRAPH.md)** | How `/graph` builds nodes and edges, and how shared terms are scored |
| 🗺️ **[Flow mapper](docs/FLOW_MAPPER.md)** | Capturing investigations, linking them, retention questions |
| 🔐 **[SSO / OAuth](docs/SSO_OAUTH.md)** · **[Secure tokens](docs/SECURE_TOKENS.md)** | Browser sign-in, secure refs, the masking matrix |
| 🔌 **[MCP server](docs/MCP.md)** | Glean as native tools in Claude Code, Claude Desktop, Cursor |
| 🏛️ **[Architecture](docs/ARCHITECTURE.md)** · **[REST paths](docs/REST_PATHS.md)** | Module map, request flow, endpoints, how to add a command |
| ✅ **[Testing](docs/TESTING.md)** | Running the 1,109-test suite and what it covers |
| ✅ **[Testing](docs/TESTING.md)** | Running the 1,148-test suite and what it covers |
| 🛟 **[Support](SUPPORT.md)** · **[Changelog](CHANGELOG.md)** | How to report a bug · release history |

> [!NOTE]
Expand Down
51 changes: 51 additions & 0 deletions docs/COMMANDS.md
Original file line number Diff line number Diff line change
Expand Up @@ -548,6 +548,57 @@ Search the Glean index and display ranked results with snippets.

---

#### /graph

Build a knowledge graph over the documents a query returns — how they relate to each other, who wrote them, and where they live. Glean's Client API has no graph endpoint, so the graph is synthesised locally from the search response, which means it works identically in mock, local and live mode.

```text
/graph <query> [--html <path>] [--page-size <n>] [--datasource <name>] [--min-shared <n>] [--no-terms]
```

| Parameter | Description |
| --- | --- |
| `query` | Free text query. The graph covers the documents it returns, not the whole index. |
| `--html` | Write a self-contained interactive page to this path. No CDN, no framework, no network. |
| `--page-size` | How many results to graph. Default 25. |
| `--datasource` | Restrict the result set to one datasource. |
| `--min-shared` | Shared terms needed before two documents are linked. Default 2. |
| `--no-terms` | Structural edges only: author, datasource, container. |

Four kinds of node, four kinds of edge, and every edge carries the evidence that produced it:

| Node | From |
| --- | --- |
| `doc` | A document in the result set |
| `person` | Its author, from `metadata.author` |
| `source` | The datasource it was indexed from |
| `container` | The folder, channel or space it lives in |

| Edge | Meaning |
| --- | --- |
| `authored_by` | `doc` → `person` |
| `in_source` | `doc` → `source` |
| `in_container` | `doc` → `container` |
| `shares_term` | `doc` ↔ `doc`, weighted by how rare the shared vocabulary is across the result set |

`shares_term` is the interesting one. Terms carried by most of the result set are dropped before scoring — those are usually the query itself — so what remains is what actually distinguishes one pair of documents from the rest. The edge's evidence names the shared words, so a link can be read rather than trusted.

```text
/graph "quarterly planning"
/graph "checkout incident" --html incident-graph.html
/graph "access review" --datasource confluence --min-shared 3
```

**Output** — A terminal summary: node and edge counts by kind, the hubs by degree, connected clusters with the node anchoring each, and the strongest content links with their shared terms. With `--html`, also an interactive page: pan, zoom, drag, and click a node to see every edge and why it exists.

**Mock mode** — Fully supported. The mock corpus carries an author, datasource and container on all seventy documents, so the graph is complete offline.

**Local mode** — `/search` resolves to the personal index, so the graph covers your own files. Local files have no author metadata, so the graph has no `person` nodes; the command says so rather than leaving you to notice.

**Endpoint** — `POST /rest/api/v1/search` (the graph itself is built locally)

---

#### /datasources.list

List all datasources visible to the current token, derived from a faceted search call.
Expand Down
105 changes: 105 additions & 0 deletions docs/GRAPH.md
Original file line number Diff line number Diff line change
@@ -0,0 +1,105 @@
# Knowledge Graph

`/graph` draws how the content behind a query relates to itself: which documents share
vocabulary, who wrote them, and where they live. It answers the question a flat list of
search results cannot — *what is this cluster of material, and who owns it?*

```text
/graph "quarterly planning"
/graph "checkout incident" --html incident-graph.html
```

## Why it is built locally

The Glean Client API has no graph endpoint. What it does return, on every search result, is
the author, the datasource, the container, and snippets of the text. That is enough to build
a graph — and building it client-side has three consequences worth stating plainly:

- **It is the graph of one query's result set**, not a crawl of the whole index. `--page-size`
is therefore the size of the graph.
- **It works identically in mock, local and live mode**, because all three return the same
response shape. No endpoint to stub, no shape to keep in sync.
- **Nothing leaves the process.** The graph, the layout and the HTML are all computed locally.

## The model

| Node | Comes from |
| --- | --- |
| `doc` | A document in the result set |
| `person` | `metadata.author` — name and email |
| `source` | `metadata.datasource` |
| `container` | `metadata.container` — a folder, channel or space |

| Edge | Meaning | Evidence it carries |
| --- | --- | --- |
| `authored_by` | `doc` → `person` | "author of this document" |
| `in_source` | `doc` → `source` | "indexed from gdrive" |
| `in_container` | `doc` → `container` | "lives in #planning" |
| `shares_term` | `doc` ↔ `doc` | The shared words themselves |

### How `shares_term` is scored

Every document's title and snippets are reduced to terms — lowercase, four characters or
more, stopwords dropped. A term is then weighted by how rare it is *within this result set*:

```
weight(term) = log(total_docs / docs_containing_term)
```

Terms appearing in more than 60% of the set are discarded before scoring, because those are
almost always the query itself: in a search for "quarterly planning", the words *quarterly*
and *planning* say nothing about how two of the results differ. An edge is drawn when two
documents share at least `--min-shared` of the surviving terms, and its score is the sum of
their weights.

This is the same idea `/flow` uses to link investigations, and for the same reason: a link you
cannot inspect is a link you have to take on trust. Every edge names its evidence, so
`shares capacity, model, fy26 (4.7)` can be judged rather than believed.

## Reading the terminal view

```text
27 nodes 10 doc · 4 person · 4 source · 9 container
40 edges 10 authored_by · 10 in_source · 10 in_container · 10 shares_term

hubs
▪ doc Questions on the capacity model numbers 7 edges
◆ person Priya Raman 4 edges

clusters (1)
27 nodes — anchored on doc Questions on the capacity model numbers

strongest content links
Headcount and Budget Model FY26
↓ shares feeds, capacity, model, fy26 (4.7)
PLAN-482 — Q4 FY26 planning: engineering capacity model
```

**Hubs** are ranked by degree, so the documents everything else touches surface first.
**Clusters** are connected components — more than one means the query pulled in unrelated
material, which is itself worth knowing. **Strongest content links** are the `shares_term`
edges in score order.

## The HTML view

`--html <path>` writes a self-contained page: one file, no CDN, no framework, no network —
the same constraint `/flow timeline` works under. Inside it:

- A force-directed layout, seeded from deterministic ring positions so the same graph opens
the same way, then relaxed in the browser.
- Nodes coloured by kind and sized by degree.
- Pan by dragging the background, zoom on scroll, drag a node to pull the layout around it.
The view auto-frames the graph while it settles and stops the moment you take over.
- Click any node for a panel listing every edge it has, each with its evidence and score.
- Light and dark, following the reader's system setting. `prefers-reduced-motion` skips the
animated relaxation and draws the settled layout directly.

## Limits worth knowing

- **Local mode has no people.** Plain files carry no author, so a graph of the personal index
has `doc`, `source` and `container` nodes only. The command tells you instead of leaving a
silent gap.
- **Layout cost is O(n²) per tick.** Fine to a few hundred nodes, which is well past a
sensible `--page-size`; it is not a whole-index visualiser.
- **Authorship is whoever the connector recorded.** A document indexed under a service account
is attributed to that account, exactly as Glean holds it.
4 changes: 2 additions & 2 deletions docs/TESTING.md
Original file line number Diff line number Diff line change
Expand Up @@ -21,13 +21,13 @@ files, and they outrank the `Glean Code.app` launcher in `Cmd+Space`:
export PYTHONPYCACHEPREFIX="$HOME/.cache/python"
```

1,109 tests covering the client and every mock response, commands and dispatch, config, UI, auth, completion, help docs, the mock corpus, indexing-walk, scaffold, the installer, the MCP server, the flow mapper, the Pages site builder, and Glean Personal (text extraction, the index, the content graph, ranking explanations, local mode, and the local MCP tools).
1,148 tests covering the client and every mock response, commands and dispatch, config, UI, auth, completion, help docs, the mock corpus, indexing-walk, scaffold, the installer, the MCP server, the flow mapper, the Pages site builder, the knowledge graph, and Glean Personal (text extraction, the index, the content graph, ranking explanations, local mode, and the local MCP tools).

## Development notes

Notes on the test suite added during development of glean-code-cli.

All 1,109 tests pass. Here's what was added across the development passes:
All 1,148 tests pass. Here's what was added across the development passes:

`tests/test_commands_extended.py` (155 new tests) — covers all previously untested commands:

Expand Down
61 changes: 61 additions & 0 deletions glean_code/commands.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from . import ui
from . import _indexing_walk as _walk
from . import flow as _flow
from . import graph as _graph
from . import mcp_control as _mcp
from . import personal as _personal
from .client import GleanClient, GleanError
Expand Down Expand Up @@ -796,6 +797,66 @@ def cmd_search(s: Session, pos, flags):
print(ui.rule())


@register("graph")
def cmd_graph(s: Session, pos, flags):
"""Knowledge graph over a query's result set: documents, authors, sources."""
if not pos:
ui.print_err('Usage: /graph <query> [--html out.html]')
return
query = " ".join(pos)
page_size = int(flags.get("page-size") or flags.get("page_size") or 25)
datasource = flags.get("datasource")
min_shared = int(flags.get("min-shared") or flags.get("min_shared") or
_graph.DEFAULT_MIN_SHARED)
with_terms = not (flags.get("no-terms") or flags.get("no_terms"))

try:
resp = s.client.search(query, page_size=page_size, datasource=datasource)
except GleanError as e:
ui.print_err(str(e))
return

results = resp.get("results", [])
# The banner belongs to the response, exactly as in /search: a graph drawn
# from the personal index must not read as one drawn from Glean.
if resp.get("localIndex"):
print(ui.style(_personal.LOCAL_BANNER, ui.C.YELLOW))
print()
if not results:
ui.print_info("No results for that query, so there is nothing to graph.")
return

mode = s.config.effective_mode
source = {"mock": "mock corpus", "local": "personal index"}.get(
mode, s.config.instance or "live")
built = _graph.build(results, query=query, source_label=source,
min_shared=min_shared, with_terms=with_terms)
summary = _graph.summarize(built)

print(ui.rule(f"graph: {query}"))
print(ui.style(f" {source}", ui.C.GREY))
print()
print(_graph.render_terminal(built, summary, ui.term_width()))
print(ui.rule())

if mode == "local":
ui.print_info("Local files carry no author, so this graph has no people in it.")

out = flags.get("html")
if out:
if out is True:
ui.print_err("--html needs a path: /graph <query> --html graph.html")
return
try:
written = _graph.write_html(Path(str(out)), built)
except OSError as e:
ui.print_err(f"Could not write {out}: {e}")
return
ui.print_ok(f"Wrote {written} ({summary['nodes']} nodes, {summary['edges']} edges)")
else:
ui.print_info("Add --html graph.html for the interactive view.")


@register("datasources.list")
def cmd_datasources_list(s: Session, pos, flags):
sample = int(flags.get("sample") or 100)
Expand Down
Loading
Loading