diff --git a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md index cd9a83b..989c082 100644 --- a/.claude/skills/gitnexus/gitnexus-cli/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-cli/SKILL.md @@ -5,14 +5,16 @@ description: "Use when the user needs to run GitNexus CLI commands like analyze/ # GitNexus CLI Commands -All commands work via `npx` — no global install required. +Commands below use `node .gitnexus/run.cjs ` — the project-local runner `gitnexus analyze` drops next to the index. It auto-selects an available runner at call time (global `gitnexus`, else `pnpm dlx`, else `npx`), so no package-manager assumption and no global install is required. + +> **Not analyzed yet, or `node .gitnexus/run.cjs` reports `Cannot find module`** (the gitignored runner is absent — e.g. a fresh clone or `git clean`)? (Re)generate it with `npx gitnexus analyze` from the project root. On **npm 11.x**, if `npx` crashes during install (`node.target is null`), install once with `npm i -g gitnexus` (then `gitnexus analyze`) or use `pnpm --allow-build=@ladybugdb/core --allow-build=gitnexus --allow-build=tree-sitter dlx gitnexus@latest analyze`. See [#1939](https://github.com/abhigyanpatwari/GitNexus/issues/1939). ## Commands ### analyze — Build or refresh the index ```bash -npx gitnexus analyze +node .gitnexus/run.cjs analyze ``` Run from the project root. This parses all source files, builds the knowledge graph, writes it to `.gitnexus/`, and generates CLAUDE.md / AGENTS.md context files. @@ -28,7 +30,7 @@ Run from the project root. This parses all source files, builds the knowledge gr ### status — Check index freshness ```bash -npx gitnexus status +node .gitnexus/run.cjs status ``` Shows whether the current repo has a GitNexus index, when it was last updated, and symbol/relationship counts. Use this to check if re-indexing is needed. @@ -36,7 +38,7 @@ Shows whether the current repo has a GitNexus index, when it was last updated, a ### clean — Delete the index ```bash -npx gitnexus clean +node .gitnexus/run.cjs clean ``` Deletes the `.gitnexus/` directory and unregisters the repo from the global registry. Use before re-indexing if the index is corrupt or after removing GitNexus from a project. @@ -49,7 +51,7 @@ Deletes the `.gitnexus/` directory and unregisters the repo from the global regi ### wiki — Generate documentation from the graph ```bash -npx gitnexus wiki +node .gitnexus/run.cjs wiki ``` Generates repository documentation from the knowledge graph using an LLM. Requires an API key (saved to `~/.gitnexus/config.json` on first use). @@ -66,7 +68,7 @@ Generates repository documentation from the knowledge graph using an LLM. Requir ### list — Show all indexed repos ```bash -npx gitnexus list +node .gitnexus/run.cjs list ``` Lists all repositories registered in `~/.gitnexus/registry.json`. The MCP `list_repos` tool provides the same information. diff --git a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md index 9510b97..9834f94 100644 --- a/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-debugging/SKILL.md @@ -16,23 +16,23 @@ description: "Use when the user is debugging a bug, tracing an error, or asking ## Workflow ``` -1. gitnexus_query({query: ""}) → Find related execution flows -2. gitnexus_context({name: ""}) → See callers/callees/processes +1. query({query: ""}) → Find related execution flows +2. context({name: ""}) → See callers/callees/processes 3. READ gitnexus://repo/{name}/process/{name} → Trace execution flow -4. gitnexus_cypher({query: "MATCH path..."}) → Custom traces if needed +4. cypher({query: "MATCH path..."}) → Custom traces if needed ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist ``` - [ ] Understand the symptom (error message, unexpected behavior) -- [ ] gitnexus_query for error text or related code +- [ ] query for error text or related code - [ ] Identify the suspect function from returned processes -- [ ] gitnexus_context to see callers and callees +- [ ] context to see callers and callees - [ ] Trace execution flow via process resource if applicable -- [ ] gitnexus_cypher for custom call chain traces if needed +- [ ] cypher for custom call chain traces if needed - [ ] Read source files to confirm root cause ``` @@ -40,7 +40,7 @@ description: "Use when the user is debugging a bug, tracing an error, or asking | Symptom | GitNexus Approach | | -------------------- | ---------------------------------------------------------- | -| Error message | `gitnexus_query` for error text → `context` on throw sites | +| Error message | `query` for error text → `context` on throw sites | | Wrong return value | `context` on the function → trace callees for data flow | | Intermittent failure | `context` → look for external calls, async deps | | Performance issue | `context` → find symbols with many callers (hot paths) | @@ -48,24 +48,24 @@ description: "Use when the user is debugging a bug, tracing an error, or asking ## Tools -**gitnexus_query** — find code related to error: +**query** — find code related to error: ``` -gitnexus_query({query: "payment validation error"}) +query({query: "payment validation error"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError, PaymentException ``` -**gitnexus_context** — full context for a suspect: +**context** — full context for a suspect: ``` -gitnexus_context({name: "validatePayment"}) +context({name: "validatePayment"}) → Incoming calls: processCheckout, webhookHandler → Outgoing calls: verifyCard, fetchRates (external API!) → Processes: CheckoutFlow (step 3/7) ``` -**gitnexus_cypher** — custom call chain traces: +**cypher** — custom call chain traces: ```cypher MATCH path = (a)-[:CodeRelation {type: 'CALLS'}*1..2]->(b:Function {name: "validatePayment"}) @@ -75,11 +75,11 @@ RETURN [n IN nodes(path) | n.name] AS chain ## Example: "Payment endpoint returns 500 intermittently" ``` -1. gitnexus_query({query: "payment error handling"}) +1. query({query: "payment error handling"}) → Processes: CheckoutFlow, ErrorHandling → Symbols: validatePayment, handlePaymentError -2. gitnexus_context({name: "validatePayment"}) +2. context({name: "validatePayment"}) → Outgoing calls: verifyCard, fetchRates (external API!) 3. READ gitnexus://repo/my-app/process/CheckoutFlow diff --git a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md index 927a4e4..ccf684c 100644 --- a/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-exploring/SKILL.md @@ -18,20 +18,20 @@ description: "Use when the user asks how code works, wants to understand archite ``` 1. READ gitnexus://repos → Discover indexed repos 2. READ gitnexus://repo/{name}/context → Codebase overview, check staleness -3. gitnexus_query({query: ""}) → Find related execution flows -4. gitnexus_context({name: ""}) → Deep dive on specific symbol +3. query({query: ""}) → Find related execution flows +4. context({name: ""}) → Deep dive on specific symbol 5. READ gitnexus://repo/{name}/process/{name} → Trace full execution flow ``` -> If step 2 says "Index is stale" → run `npx gitnexus analyze` in terminal. +> If step 2 says "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist ``` - [ ] READ gitnexus://repo/{name}/context -- [ ] gitnexus_query for the concept you want to understand +- [ ] query for the concept you want to understand - [ ] Review returned processes (execution flows) -- [ ] gitnexus_context on key symbols for callers/callees +- [ ] context on key symbols for callers/callees - [ ] READ process resource for full execution traces - [ ] Read source files for implementation details ``` @@ -47,18 +47,18 @@ description: "Use when the user asks how code works, wants to understand archite ## Tools -**gitnexus_query** — find execution flows related to a concept: +**query** — find execution flows related to a concept: ``` -gitnexus_query({query: "payment processing"}) +query({query: "payment processing"}) → Processes: CheckoutFlow, RefundFlow, WebhookHandler → Symbols grouped by flow with file locations ``` -**gitnexus_context** — 360-degree view of a symbol: +**context** — 360-degree view of a symbol: ``` -gitnexus_context({name: "validateUser"}) +context({name: "validateUser"}) → Incoming calls: loginHandler, apiMiddleware → Outgoing calls: checkToken, getUserById → Processes: LoginFlow (step 2/5), TokenRefresh (step 1/3) @@ -68,10 +68,10 @@ gitnexus_context({name: "validateUser"}) ``` 1. READ gitnexus://repo/my-app/context → 918 symbols, 45 processes -2. gitnexus_query({query: "payment processing"}) +2. query({query: "payment processing"}) → CheckoutFlow: processPayment → validateCard → chargeStripe → RefundFlow: initiateRefund → calculateRefund → processRefund -3. gitnexus_context({name: "processPayment"}) +3. context({name: "processPayment"}) → Incoming: checkoutHandler, webhookHandler → Outgoing: validateCard, chargeStripe, saveTransaction 4. Read src/payments/processor.ts for implementation details diff --git a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md index 937ac73..a543378 100644 --- a/.claude/skills/gitnexus/gitnexus-guide/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-guide/SKILL.md @@ -15,7 +15,7 @@ For any task involving code understanding, debugging, impact analysis, or refact 2. **Match your task to a skill below** and **read that skill file** 3. **Follow the skill's workflow and checklist** -> If step 1 warns the index is stale, run `npx gitnexus analyze` in the terminal first. +> If step 1 warns the index is stale, run `node .gitnexus/run.cjs analyze` in the terminal first. ## Skills @@ -38,7 +38,38 @@ For any task involving code understanding, debugging, impact analysis, or refact | `detect_changes` | Git-diff impact — what do your current changes affect | | `rename` | Multi-file coordinated rename with confidence-tagged edits | | `cypher` | Raw graph queries (read `gitnexus://repo/{name}/schema` first) | -| `list_repos` | Discover indexed repos | +| `list_repos` | Discover indexed repos (paginated — `limit`/`offset`) | + +### Paginating `list_repos` + +`list_repos` is paginated so a large registry is not truncated by MCP/LLM token limits. It takes optional `limit` (default **50**, max **200**) and `offset`, and returns: + +```jsonc +{ + "repositories": [ + { "name": "...", "path": "...", "indexedAt": "...", "lastCommit": "...", "stats": { } } + ], + "pagination": { + "total": 437, + "limit": 50, + "offset": 0, + "returned": 50, + "hasMore": true, + "nextOffset": 50 + } +} +``` + +To enumerate **every** repository, keep calling with `offset` set to `pagination.nextOffset` until `hasMore` is `false`: + +```text +list_repos {} → repos 1–50, nextOffset 50, hasMore true +list_repos { offset: 50 } → repos 51–100, nextOffset 100, hasMore true +… +list_repos { offset: 400 } → repos 401–437, hasMore false (done) +``` + +Notes: `offset` ≥ `total` returns an empty page (with `total` still reported). Out-of-range or malformed `limit`/`offset` (non-integer, `limit` outside `[1, 200]`, `offset < 0`) are rejected with a clear error — `limit` above the max is rejected, not silently capped. The order is deterministic (lower-cased name, then path), so paging never skips or duplicates an entry while the registry is unchanged. ## Resources Reference diff --git a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md index e19af28..45eb7ce 100644 --- a/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-impact-analysis/SKILL.md @@ -17,22 +17,22 @@ description: "Use when the user wants to know what will break if they change som ## Workflow ``` -1. gitnexus_impact({target: "X", direction: "upstream"}) → What depends on this +1. impact({target: "X", direction: "upstream"}) → What depends on this 2. READ gitnexus://repo/{name}/processes → Check affected execution flows -3. gitnexus_detect_changes() → Map current git changes to affected flows +3. detect_changes() → Map current git changes to affected flows 4. Assess risk and report to user ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklist ``` -- [ ] gitnexus_impact({target, direction: "upstream"}) to find dependents +- [ ] impact({target, direction: "upstream"}) to find dependents - [ ] Review d=1 items first (these WILL BREAK) - [ ] Check high-confidence (>0.8) dependencies - [ ] READ processes to check affected execution flows -- [ ] gitnexus_detect_changes() for pre-commit check +- [ ] detect_changes() for pre-commit check - [ ] Assess risk level and report to user ``` @@ -55,10 +55,10 @@ description: "Use when the user wants to know what will break if they change som ## Tools -**gitnexus_impact** — the primary tool for symbol blast radius: +**impact** — the primary tool for symbol blast radius: ``` -gitnexus_impact({ +impact({ target: "validateUser", direction: "upstream", minConfidence: 0.8, @@ -73,10 +73,10 @@ gitnexus_impact({ - authRouter (src/routes/auth.ts:22) [CALLS, 95%] ``` -**gitnexus_detect_changes** — git-diff based impact analysis: +**detect_changes** — git-diff based impact analysis: ``` -gitnexus_detect_changes({scope: "staged"}) +detect_changes({scope: "staged"}) → Changed: 5 symbols in 3 files → Affected: LoginFlow, TokenRefresh, APIMiddlewarePipeline @@ -86,7 +86,7 @@ gitnexus_detect_changes({scope: "staged"}) ## Example: "What breaks if I change validateUser?" ``` -1. gitnexus_impact({target: "validateUser", direction: "upstream"}) +1. impact({target: "validateUser", direction: "upstream"}) → d=1: loginHandler, apiMiddleware (WILL BREAK) → d=2: authRouter, sessionManager (LIKELY AFFECTED) diff --git a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md index f48cc01..e13c04e 100644 --- a/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md +++ b/.claude/skills/gitnexus/gitnexus-refactoring/SKILL.md @@ -16,78 +16,78 @@ description: "Use when the user wants to rename, extract, split, move, or restru ## Workflow ``` -1. gitnexus_impact({target: "X", direction: "upstream"}) → Map all dependents -2. gitnexus_query({query: "X"}) → Find execution flows involving X -3. gitnexus_context({name: "X"}) → See all incoming/outgoing refs +1. impact({target: "X", direction: "upstream"}) → Map all dependents +2. query({query: "X"}) → Find execution flows involving X +3. context({name: "X"}) → See all incoming/outgoing refs 4. Plan update order: interfaces → implementations → callers → tests ``` -> If "Index is stale" → run `npx gitnexus analyze` in terminal. +> If "Index is stale" → run `node .gitnexus/run.cjs analyze` in terminal. ## Checklists ### Rename Symbol ``` -- [ ] gitnexus_rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits +- [ ] rename({symbol_name: "oldName", new_name: "newName", dry_run: true}) — preview all edits - [ ] Review graph edits (high confidence) and ast_search edits (review carefully) -- [ ] If satisfied: gitnexus_rename({..., dry_run: false}) — apply edits -- [ ] gitnexus_detect_changes() — verify only expected files changed +- [ ] If satisfied: rename({..., dry_run: false}) — apply edits +- [ ] detect_changes() — verify only expected files changed - [ ] Run tests for affected processes ``` ### Extract Module ``` -- [ ] gitnexus_context({name: target}) — see all incoming/outgoing refs -- [ ] gitnexus_impact({target, direction: "upstream"}) — find all external callers +- [ ] context({name: target}) — see all incoming/outgoing refs +- [ ] impact({target, direction: "upstream"}) — find all external callers - [ ] Define new module interface - [ ] Extract code, update imports -- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] detect_changes() — verify affected scope - [ ] Run tests for affected processes ``` ### Split Function/Service ``` -- [ ] gitnexus_context({name: target}) — understand all callees +- [ ] context({name: target}) — understand all callees - [ ] Group callees by responsibility -- [ ] gitnexus_impact({target, direction: "upstream"}) — map callers to update +- [ ] impact({target, direction: "upstream"}) — map callers to update - [ ] Create new functions/services - [ ] Update callers -- [ ] gitnexus_detect_changes() — verify affected scope +- [ ] detect_changes() — verify affected scope - [ ] Run tests for affected processes ``` ## Tools -**gitnexus_rename** — automated multi-file rename: +**rename** — automated multi-file rename: ``` -gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) → 12 edits across 8 files → 10 graph edits (high confidence), 2 ast_search edits (review) → Changes: [{file_path, edits: [{line, old_text, new_text, confidence}]}] ``` -**gitnexus_impact** — map all dependents first: +**impact** — map all dependents first: ``` -gitnexus_impact({target: "validateUser", direction: "upstream"}) +impact({target: "validateUser", direction: "upstream"}) → d=1: loginHandler, apiMiddleware, testUtils → Affected Processes: LoginFlow, TokenRefresh ``` -**gitnexus_detect_changes** — verify your changes after refactoring: +**detect_changes** — verify your changes after refactoring: ``` -gitnexus_detect_changes({scope: "all"}) +detect_changes({scope: "all"}) → Changed: 8 files, 12 symbols → Affected processes: LoginFlow, TokenRefresh → Risk: MEDIUM ``` -**gitnexus_cypher** — custom reference queries: +**cypher** — custom reference queries: ```cypher MATCH (caller)-[:CodeRelation {type: 'CALLS'}]->(f:Function {name: "validateUser"}) @@ -98,24 +98,24 @@ RETURN caller.name, caller.filePath ORDER BY caller.filePath | Risk Factor | Mitigation | | ------------------- | ----------------------------------------- | -| Many callers (>5) | Use gitnexus_rename for automated updates | +| Many callers (>5) | Use rename for automated updates | | Cross-area refs | Use detect_changes after to verify scope | -| String/dynamic refs | gitnexus_query to find them | +| String/dynamic refs | query to find them | | External/public API | Version and deprecate properly | ## Example: Rename `validateUser` to `authenticateUser` ``` -1. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) +1. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: true}) → 12 edits: 10 graph (safe), 2 ast_search (review) → Files: validator.ts, login.ts, middleware.ts, config.json... 2. Review ast_search edits (config.json: dynamic reference!) -3. gitnexus_rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) +3. rename({symbol_name: "validateUser", new_name: "authenticateUser", dry_run: false}) → Applied 12 edits across 8 files -4. gitnexus_detect_changes({scope: "all"}) +4. detect_changes({scope: "all"}) → Affected: LoginFlow, TokenRefresh → Risk: MEDIUM — run tests for these flows ``` diff --git a/AGENTS.md b/AGENTS.md index ea7afe9..b0c81ae 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -83,24 +83,24 @@ Ask. Refusing to act is always safer than taking an action that bypasses these r # GitNexus — Code Intelligence -This project is indexed by GitNexus as **supervaizer** (6273 symbols, 11483 relationships, 281 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. +This project is indexed by GitNexus as **supervaizer** (6281 symbols, 11689 relationships, 286 execution flows). Use the GitNexus MCP tools to understand code, assess impact, and navigate safely. -> If any GitNexus tool warns the index is stale, run `npx gitnexus analyze` in terminal first. +> Index stale? Run `node .gitnexus/run.cjs analyze` from the project root — it auto-selects an available runner. No `.gitnexus/run.cjs` yet? `npx gitnexus analyze` (npm 11 crash → `npm i -g gitnexus`; #1939). ## Always Do -- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `gitnexus_impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. -- **MUST run `gitnexus_detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. +- **MUST run impact analysis before editing any symbol.** Before modifying a function, class, or method, run `impact({target: "symbolName", direction: "upstream"})` and report the blast radius (direct callers, affected processes, risk level) to the user. +- **MUST run `detect_changes()` before committing** to verify your changes only affect expected symbols and execution flows. For regression review, compare against the default branch: `detect_changes({scope: "compare", base_ref: "main"})`. - **MUST warn the user** if impact analysis returns HIGH or CRITICAL risk before proceeding with edits. -- When exploring unfamiliar code, use `gitnexus_query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. -- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `gitnexus_context({name: "symbolName"})`. +- When exploring unfamiliar code, use `query({query: "concept"})` to find execution flows instead of grepping. It returns process-grouped results ranked by relevance. +- When you need full context on a specific symbol — callers, callees, which execution flows it participates in — use `context({name: "symbolName"})`. ## Never Do -- NEVER edit a function, class, or method without first running `gitnexus_impact` on it. +- NEVER edit a function, class, or method without first running `impact` on it. - NEVER ignore HIGH or CRITICAL risk warnings from impact analysis. -- NEVER rename symbols with find-and-replace — use `gitnexus_rename` which understands the call graph. -- NEVER commit changes without running `gitnexus_detect_changes()` to check affected scope. +- NEVER rename symbols with find-and-replace — use `rename` which understands the call graph. +- NEVER commit changes without running `detect_changes()` to check affected scope. ## Resources @@ -113,7 +113,7 @@ This project is indexed by GitNexus as **supervaizer** (6273 symbols, 11483 rela ## Cross-Repo Groups -This repository is listed under GitNexus **group(s): runwaize** (see `~/.gitnexus/groups/`). For cross-repo analysis, use MCP tools `impact`, `query`, and `context` with `repo` set to `@` or `@/` (paths match keys in that group’s `group.yaml`). Use `group_list` / `group_sync` for membership and sync. From the terminal: `npx gitnexus group list`, `npx gitnexus group sync `, `npx gitnexus group impact --target --repo `. +This repository is listed under GitNexus **group(s): runwaize** (see `~/.gitnexus/groups/`). For cross-repo analysis, use MCP tools `impact`, `query`, and `context` with `repo` set to `@` or `@/` (paths match keys in that group’s `group.yaml`). Use `group_list` / `group_sync` for membership and sync. From the project root: `node .gitnexus/run.cjs group list`, `node .gitnexus/run.cjs group sync `, `node .gitnexus/run.cjs group impact --target --repo ` (the `.gitnexus/run.cjs` path is repo-root-relative). ## CLI diff --git a/src/supervaizer/__init__.py b/src/supervaizer/__init__.py index a293f09..be2024a 100644 --- a/src/supervaizer/__init__.py +++ b/src/supervaizer/__init__.py @@ -35,6 +35,13 @@ "CaseNodeType": ("supervaizer.case", "CaseNodeType"), "CaseNodeUpdate": ("supervaizer.case", "CaseNodeUpdate"), "Cases": ("supervaizer.case", "Cases"), + "context": ("supervaizer.context", None), + "ContextCitation": ("supervaizer.context", "ContextCitation"), + "ContextClient": ("supervaizer.context", "ContextClient"), + "ContextOpenResponse": ("supervaizer.context", "ContextOpenResponse"), + "ContextSearchResponse": ("supervaizer.context", "ContextSearchResponse"), + "ContextSearchResult": ("supervaizer.context", "ContextSearchResult"), + "ContextScope": ("supervaizer.context", "ContextScope"), "DataResource": ("supervaizer.data_resource", "DataResource"), "DataResourceContext": ("supervaizer.data_resource", "DataResourceContext"), "DataResourceField": ("supervaizer.data_resource", "DataResourceField"), diff --git a/src/supervaizer/account.py b/src/supervaizer/account.py index 4afe77f..4120baf 100644 --- a/src/supervaizer/account.py +++ b/src/supervaizer/account.py @@ -18,6 +18,7 @@ from supervaizer.__version__ import VERSION from supervaizer.common import ApiError, ApiResult, ApiSuccess, SvBaseModel +from supervaizer.context import ContextClient from supervaizer.telemetry import Telemetry if TYPE_CHECKING: @@ -128,6 +129,11 @@ def api_headers(self) -> dict[str, str]: "workspace": self.workspace_id, } + @property + def context(self) -> ContextClient: + """Client for Supervaize-managed runtime context.""" + return ContextClient(self) + @property def api_url_team(self) -> str: """URL for the Supervaize workspace team.""" diff --git a/src/supervaizer/context.py b/src/supervaizer/context.py new file mode 100644 index 0000000..f332e22 --- /dev/null +++ b/src/supervaizer/context.py @@ -0,0 +1,172 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +import atexit +import os +from typing import TYPE_CHECKING, Any, Literal + +import httpx +from pydantic import Field + +from supervaizer.common import SvBaseModel + +if TYPE_CHECKING: + from supervaizer.account import Account + +ContextScope = Literal["workspace", "mission"] + +_sync_httpx_transport = httpx.HTTPTransport( + retries=int(os.getenv("SUPERVAIZE_HTTP_MAX_RETRIES", 2)) +) +_sync_httpx_client = httpx.Client(transport=_sync_httpx_transport) + + +class ContextCitation(SvBaseModel): + ref: str + title: str + source_type: str + version: int + + +class ContextSearchResult(SvBaseModel): + ref: str + title: str + scope: ContextScope + source_type: str + version: int + tags: list[str] = Field(default_factory=list) + excerpt: str = "" + score: int = 0 + citation: ContextCitation | None = None + + +class ContextSearchResponse(SvBaseModel): + query: str + results: list[ContextSearchResult] = Field(default_factory=list) + + +class ContextOpenResponse(SvBaseModel): + ref: str + title: str + scope: ContextScope + source_type: str + version: int + instructions: str = "" + tags: list[str] = Field(default_factory=list) + content: str + citations: list[ContextCitation] = Field(default_factory=list) + + +class ContextClient: + def __init__(self, account: Account) -> None: + self.account: Account = account + + def search( + self, + *, + query: str, + mission_id: str | None = None, + scope: ContextScope | None = None, + tags: list[str] | None = None, + limit: int = 5, + expected_workspace_id: str | None = None, + ) -> ContextSearchResponse: + self._reject_conflicting_workspace(expected_workspace_id) + payload: dict[str, Any] = {"query": query, "limit": limit} + self._add_mission_id(payload, mission_id) + if scope: + payload["scope"] = scope + if tags: + payload["tags"] = tags + response = _sync_httpx_client.post( + self._url("search"), headers=self.account.api_headers, json=payload + ) + response.raise_for_status() + return ContextSearchResponse.model_validate(response.json()) + + def open( + self, + *, + ref: str, + mission_id: str | None = None, + query: str | None = None, + max_chars: int = 3000, + expected_workspace_id: str | None = None, + ) -> ContextOpenResponse: + self._reject_conflicting_workspace(expected_workspace_id) + payload: dict[str, Any] = {"ref": ref, "max_chars": max_chars} + self._add_mission_id(payload, mission_id) + if query: + payload["query"] = query + response = _sync_httpx_client.post( + self._url("open"), headers=self.account.api_headers, json=payload + ) + response.raise_for_status() + return ContextOpenResponse.model_validate(response.json()) + + def _url(self, action: str) -> str: + return f"{self.account.api_url_w_v1}/context/{action}/" + + def _reject_conflicting_workspace(self, expected_workspace_id: str | None) -> None: + if expected_workspace_id and expected_workspace_id != self.account.workspace_id: + raise ValueError( + f"expected_workspace_id {expected_workspace_id!r} does not match " + f"account.workspace_id {self.account.workspace_id!r}" + ) + + def _add_mission_id(self, payload: dict[str, Any], mission_id: str | None) -> None: + if mission_id is None: + return + if not mission_id.strip(): + raise ValueError("mission_id must be a non-empty string when provided") + payload["mission_id"] = mission_id + + +def search( + *, + account: Account, + query: str, + mission_id: str | None = None, + scope: ContextScope | None = None, + tags: list[str] | None = None, + limit: int = 5, + expected_workspace_id: str | None = None, +) -> ContextSearchResponse: + return ContextClient(account).search( + query=query, + mission_id=mission_id, + scope=scope, + tags=tags, + limit=limit, + expected_workspace_id=expected_workspace_id, + ) + + +def open( + *, + account: Account, + ref: str, + mission_id: str | None = None, + query: str | None = None, + max_chars: int = 3000, + expected_workspace_id: str | None = None, +) -> ContextOpenResponse: + return ContextClient(account).open( + ref=ref, + mission_id=mission_id, + query=query, + max_chars=max_chars, + expected_workspace_id=expected_workspace_id, + ) + + +def close_httpx_client_sync() -> None: + _sync_httpx_client.close() + + +atexit.register(close_httpx_client_sync) diff --git a/tests/test_context_client.py b/tests/test_context_client.py new file mode 100644 index 0000000..2cc9007 --- /dev/null +++ b/tests/test_context_client.py @@ -0,0 +1,162 @@ +# Copyright (c) 2024-2026 Alain Prasquier - Supervaize.com. All rights reserved. +# +# This Source Code Form is subject to the terms of the Mozilla Public License, v. 2.0. +# If a copy of the MPL was not distributed with this file, you can obtain one at +# https://mozilla.org/MPL/2.0/. + +from __future__ import annotations + +from typing import Any + +import pytest + +from supervaizer import ( + Account, + ContextClient, + ContextOpenResponse, + ContextSearchResponse, + context, +) + + +class _Response: + def __init__(self, data: dict[str, Any]) -> None: + self._data: dict[str, Any] = data + + def raise_for_status(self) -> None: + return None + + def json(self) -> dict[str, Any]: + return self._data + + +def test_context_search_posts_to_workspace_context_search( + account_fixture: Account, mocker: Any +) -> None: + post = mocker.patch( + "supervaizer.context._sync_httpx_client.post", + return_value=_Response({ + "query": "billing", + "results": [ + { + "ref": "supervaize.context.workspace.billing", + "title": "Billing", + "scope": "workspace", + "source_type": "text", + "version": 1, + "tags": ["ops"], + "excerpt": "Billing policy", + "score": 3, + "citation": { + "ref": "supervaize.context.workspace.billing", + "title": "Billing", + "source_type": "text", + "version": 1, + }, + } + ], + }), + ) + + result = context.search( + account=account_fixture, + query="billing", + mission_id="mission-1", + scope="mission", + tags=["ops"], + limit=7, + ) + + assert isinstance(result, ContextSearchResponse) + assert result.results[0].ref == "supervaize.context.workspace.billing" + post.assert_called_once_with( + f"{account_fixture.api_url_w_v1}/context/search/", + headers=account_fixture.api_headers, + json={ + "query": "billing", + "limit": 7, + "mission_id": "mission-1", + "scope": "mission", + "tags": ["ops"], + }, + ) + + +def test_context_open_posts_to_workspace_context_open( + account_fixture: Account, mocker: Any +) -> None: + post = mocker.patch( + "supervaizer.context._sync_httpx_client.post", + return_value=_Response({ + "ref": "supervaize.context.workspace.billing", + "title": "Billing", + "scope": "workspace", + "source_type": "text", + "version": 2, + "instructions": "Use for policy answers.", + "tags": ["ops"], + "content": "Billing policy", + "citations": [ + { + "ref": "supervaize.context.workspace.billing", + "title": "Billing", + "source_type": "text", + "version": 2, + } + ], + }), + ) + + result = account_fixture.context.open( + ref="supervaize.context.workspace.billing", query="policy", max_chars=1000 + ) + + assert isinstance(result, ContextOpenResponse) + assert result.content == "Billing policy" + post.assert_called_once_with( + f"{account_fixture.api_url_w_v1}/context/open/", + headers=account_fixture.api_headers, + json={ + "ref": "supervaize.context.workspace.billing", + "max_chars": 1000, + "query": "policy", + }, + ) + + +def test_context_rejects_conflicting_workspace_id( + account_fixture: Account, mocker: Any +) -> None: + post = mocker.patch("supervaizer.context._sync_httpx_client.post") + client = ContextClient(account_fixture) + + with pytest.raises( + ValueError, match="expected_workspace_id .* does not match account.workspace_id" + ): + client.search(query="x", expected_workspace_id="other-workspace") + + post.assert_not_called() + + +def test_context_search_rejects_empty_mission_id( + account_fixture: Account, mocker: Any +) -> None: + post = mocker.patch("supervaizer.context._sync_httpx_client.post") + + with pytest.raises(ValueError, match="mission_id must be a non-empty string"): + context.search(account=account_fixture, query="billing", mission_id="") + + post.assert_not_called() + + +def test_context_open_rejects_empty_mission_id( + account_fixture: Account, mocker: Any +) -> None: + post = mocker.patch("supervaizer.context._sync_httpx_client.post") + + with pytest.raises(ValueError, match="mission_id must be a non-empty string"): + account_fixture.context.open( + ref="supervaize.context.workspace.billing", mission_id=" " + ) + + post.assert_not_called()