diff --git a/.gitignore b/.gitignore
index eb63517a..ed14196b 100644
--- a/.gitignore
+++ b/.gitignore
@@ -43,6 +43,7 @@ yarn-error.log*
# Local project runtime state
.ktx/
+**/.devtools/
*.db
*.sqlite
*.sqlite3
diff --git a/README.md b/README.md
index 84592226..d4e9c5d9 100644
--- a/README.md
+++ b/README.md
@@ -1,5 +1,5 @@
-
+
@@ -43,6 +43,7 @@ SQLite.
Install the CLI and run the setup wizard:
```bash
+npm install @kaelio/ktx
npm install -g @kaelio/ktx
ktx setup
```
@@ -70,6 +71,40 @@ KTX context built: yes
Agent integration ready: yes (claude-code:project)
```
+Run the packaged demo without installing globally:
+
+```bash
+npx @kaelio/ktx setup demo --no-input
+npx @kaelio/ktx setup demo inspect
+```
+
+The default demo uses packaged sample data and prebuilt context. It does not
+require API keys, network access, or an LLM provider.
+
+Generate SQL from a semantic-layer source:
+
+```bash
+npx @kaelio/ktx sl query --project-dir "$PROJECT_DIR" \
+ --connection-id warehouse \
+ --measure accounts.account_count \
+ --dimension accounts.segment \
+ --format sql
+```
+
+List and test a configured warehouse connection:
+
+```bash
+ktx connection list --project-dir "$PROJECT_DIR"
+ktx connection test warehouse --project-dir "$PROJECT_DIR"
+```
+
+The connection test prints the configured driver and discovered table count:
+
+```text
+Driver: sqlite
+Tables: 1
+```
+
## What's in a project
```
@@ -97,6 +132,47 @@ Semantic sources and knowledge pages are committed to git. The `.ktx/` directory
holds ephemeral state and is git-ignored — delete it and KTX rebuilds on the
next run.
+### Scan the demo warehouse
+
+Scan artifacts are written under
+`raw-sources/warehouse/live-database//` in the project directory.
+
+```bash
+SCAN_OUTPUT="$(ktx scan warehouse --project-dir "$PROJECT_DIR")"
+printf '%s\n' "$SCAN_OUTPUT"
+SCAN_RUN_ID="$(printf '%s\n' "$SCAN_OUTPUT" | awk '/^Run: / { print $2 }')"
+ktx scan status --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
+ktx scan report --project-dir "$PROJECT_DIR" "$SCAN_RUN_ID"
+```
+
+For non-SQLite drivers, prefer credential references such as `--url env:NAME`
+or `--url file:PATH` over literal credential URLs.
+
+## Managed Python runtime
+
+KTX installs its Python runtime only when a Python-backed command needs it.
+The runtime lives outside the npm cache, is versioned by the installed CLI
+version, and is managed by `ktx runtime` commands.
+
+KTX requires `uv` on `PATH` to create the managed runtime. Install `uv` with
+your system package manager or the official installer before running Python-
+backed KTX commands. KTX doesn't download `uv` automatically; run
+`ktx runtime doctor` if runtime installation fails:
+
+```bash
+ktx runtime install --yes
+ktx runtime status
+ktx runtime doctor
+ktx runtime start
+ktx runtime stop
+ktx runtime prune --dry-run
+ktx runtime prune --yes
+```
+
+The release artifact manifest contains the public npm tarball and the bundled `kaelio-ktx`
+runtime wheel. The `python/ktx-sl` and `python/ktx-daemon` directories remain
+source packages for development, not public release artifacts.
+
## Serve agents
KTX integrates with coding agents through CLI skills, an MCP server, or both.
@@ -126,6 +202,11 @@ This exposes tools for connections, knowledge search, semantic-layer sources,
validation, queries, ingestion, and replay. The `--semantic-compute` flag starts
the managed Python runtime for query planning automatically.
+The standalone MCP server exposes `connection_list`, `knowledge_search`,
+`knowledge_read`, `knowledge_write`, `sl_list_sources`, `sl_read_source`,
+`sl_write_source`, `sl_validate`, `sl_query`, `ingest_trigger`,
+`ingest_status`, `ingest_report`, and `ingest_replay`.
+
Supported agents: Claude Code, Codex, Cursor, OpenCode, and any agent that
reads `.agents/` skills or MCP configuration.
@@ -136,7 +217,13 @@ reads `.agents/` skills or MCP configuration.
| `packages/cli` | CLI entry point |
| `packages/context` | Core context engine |
| `packages/llm` | LLM and embedding providers |
-| `packages/connector-*` | Database connectors (Postgres, Snowflake, BigQuery, ClickHouse, MySQL, SQL Server, SQLite) |
+| `packages/connector-bigquery` | BigQuery scan connector |
+| `packages/connector-clickhouse` | ClickHouse scan connector |
+| `packages/connector-mysql` | MySQL scan connector |
+| `packages/connector-postgres` | Postgres scan connector |
+| `packages/connector-snowflake` | Snowflake scan connector |
+| `packages/connector-sqlite` | SQLite scan connector |
+| `packages/connector-sqlserver` | SQL Server scan connector |
| `python/ktx-sl` | Semantic-layer query planning |
| `python/ktx-daemon` | Portable compute service |
@@ -159,6 +246,29 @@ pnpm run link:dev
ktx-dev --help
```
+### Debug LLM traces
+
+KTX can capture local AI SDK DevTools traces for LLM calls that run through the
+KTX provider. Enable it with an environment flag when running an LLM-backed
+command:
+
+```bash
+KTX_AI_DEVTOOLS_ENABLED=true ktx dev ingest run \
+ --connection-id warehouse \
+ --adapter metabase
+```
+
+Traces are written to `.devtools/generations.json` under the current working
+directory. To inspect them, run:
+
+```bash
+pnpm dlx @ai-sdk/devtools
+```
+
+Then open `http://localhost:4983`. These traces are local-development-only and
+store prompts, model outputs, tool arguments/results, and raw provider payloads
+in plain text. Do not enable this in production or for sensitive runs.
+
The repository uses `pnpm` for TypeScript packages and `uv` for Python
packages. See [Contributing](docs-site/content/docs/community/contributing.mdx)
for full development setup, testing, and PR guidelines.
diff --git a/assets/ktx-lockup.svg b/assets/ktx-lockup.svg
new file mode 100644
index 00000000..f1bcd2dd
--- /dev/null
+++ b/assets/ktx-lockup.svg
@@ -0,0 +1,32 @@
+
diff --git a/assets/ktx-readme-header.png b/assets/ktx-readme-header.png
deleted file mode 100644
index 11cfb4e4..00000000
Binary files a/assets/ktx-readme-header.png and /dev/null differ
diff --git a/docs-site/content/docs/integrations/primary-sources.mdx b/docs-site/content/docs/integrations/primary-sources.mdx
index c36260d1..dcfd143f 100644
--- a/docs-site/content/docs/integrations/primary-sources.mdx
+++ b/docs-site/content/docs/integrations/primary-sources.mdx
@@ -213,7 +213,7 @@ For multiple datasets:
| Method | Config |
|--------|--------|
| Service account JSON | `credentials_json: file:/path/to/key.json` |
-| Environment variable | `credentials_json: env:GCP_CREDENTIALS_JSON` |
+| Environment variable | `credentials_json: env:BIGQUERY_CREDENTIALS_JSON` |
The project ID is extracted automatically from the service account JSON file.
diff --git a/examples/orbit-relationship-verification/README.md b/examples/orbit-relationship-verification/README.md
index 245411b6..126488a2 100644
--- a/examples/orbit-relationship-verification/README.md
+++ b/examples/orbit-relationship-verification/README.md
@@ -29,5 +29,5 @@ examples/orbit-relationship-verification/reports/orbit-verification.md
Use a real local Orbit project by overriding the project directory:
```bash
-KTX_ORBIT_PROJECT_DIR=/path/to/orbit-project pnpm run relationships:verify-orbit
+KTX_PROJECT_DIR=/path/to/orbit-project pnpm run relationships:verify-orbit
```
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md b/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md
index 5e827355..2059329d 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/new-hire-onboarding-policy.md
@@ -12,7 +12,7 @@ refs:
## New Hire Week-One Onboarding Policy
-**Source:** Notion — People & Operating Norms, last edited 2026-05-07
+**Source:** Notion — People & Operating Norms, last edited 2026-05-07
**Owner:** Manager (not People Ops)
---
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md
index 7998c35a..dc97bdc2 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-kpi-glossary.md
@@ -20,7 +20,7 @@ tables:
# Activation KPI Glossary
-**Owner team:** Growth
+**Owner team:** Growth
**Source:** Notion — Orbit Demo Home / Data Team - Onboarding / Activation KPI Glossary, last edited 2026-05-07
Use this when a question is about signup-to-habit behavior. Orbit uses activation language across Growth, Product, and CS conversations.
@@ -62,4 +62,3 @@ Growth conversations typically use D7 and D14 Activation Rate. Product and CS ma
## Relationship to Account-Level Activation
This glossary defines **customer-level** activation (signup-to-habit). The **account-level** activation workflow (requester login → first approved purchase request → account activated) is a separate concept tracked in `mart_account_activity` and governed by the January 2026 policy change. See `orbit-activation-policy-change-jan-2026` for that definition.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md
index 675abb63..3216d94d 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-activation-policy-change-jan-2026.md
@@ -14,9 +14,9 @@ sl_refs:
# Activation Policy Change — January 2026
-**Governed metric key:** `activated_accounts`
-**Owner team:** growth
-**Notion:** `notion://notion_page_activation_policy_decision#policy-change`
+**Governed metric key:** `activated_accounts`
+**Owner team:** growth
+**Notion:** `notion://notion_page_activation_policy_decision#policy-change`
**Sources:** `mart_account_activity`, `int_activation_policy_windows`, `stg_activation_events`
## Policy Boundary
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md
index ac8cd076..4cb34b76 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-arr-contract-first-definition.md
@@ -15,9 +15,9 @@ sl_refs:
# ARR — Contract-First Definition
-**Governed metric key:** `arr`
-**Owner team:** finance
-**Notion:** `notion://notion_page_arr_contract_reporting#arr-contract-first`
+**Governed metric key:** `arr`
+**Owner team:** finance
+**Notion:** `notion://notion_page_arr_contract_reporting#arr-contract-first`
**Source:** `mart_arr_daily` (grain: `metric_date`)
## Rule
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md
index 6cf4afac..83645aeb 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-company-overview.md
@@ -21,7 +21,7 @@ refs:
Orbit sells procurement workflow and spend-control software. The core value proposition: route purchase requests, collect approvals, onboard suppliers, and issue purchase orders without turning every exception into a status hunt.
-**Primary buyers:** Finance, Procurement, Business Operations.
+**Primary buyers:** Finance, Procurement, Business Operations.
**Daily users:** department admins, office managers, IT leads, legal ops partners — anyone who has to get a vendor through the building.
## Product Workflow
@@ -69,4 +69,3 @@ Orbit sells procurement workflow and spend-control software. The core value prop
- "Supplier onboarding is split across three teams."
- "Renewals are visible too late."
- "People keep asking Finance for status because there is nowhere better to look."
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md
index 4457bd21..56deb3f3 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customer-health-risk-definition.md
@@ -14,9 +14,9 @@ sl_refs:
# Customer Health Risk Definition
-**Governed metric key:** `active_customers`
-**Owner team:** customer_success
-**Notion:** `notion://notion_page_customer_health_playbook#risk-definition`
+**Governed metric key:** `active_customers`
+**Owner team:** customer_success
+**Notion:** `notion://notion_page_customer_health_playbook#risk-definition`
**Sources:** `mart_customer_health`, `int_customer_health_signals`
## Risk Levels
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md
index 3822e31e..e98c1663 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-customers-source.md
@@ -18,8 +18,8 @@ tables:
# Orbit Customers Source
-**Table:** `orbit_analytics.customer`
-**Grain:** one row per signed-up customer
+**Table:** `orbit_analytics.customer`
+**Grain:** one row per signed-up customer
**Source:** Notion — Orbit Demo Home / Data Team - Onboarding / Orbit Customers Source, last edited 2026-05-07
Use this when a question needs customer identity, plan tier, signup timing, recent activity, or the standard customer joins.
@@ -58,4 +58,3 @@ Always join through `customer.id`. Do not join on `email`.
- **Timezone:** `created_at` and `last_seen_at` are UTC. Confirm whether a question expects UTC or a local business day before filtering.
- **Paying vs. all:** `free` customers must be excluded from paying-customer follow-ups. Use `paying_customer_count`, not `customer_count`.
- **plan_tier values:** `free`, `pro`, `enterprise`. Note: `pro_plus` is a legacy alias for `growth` in the account/contract layer (see `orbit-plan-segment-normalization`), but `plan_tier` on this table uses `pro` not `pro_plus`.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md
index 981d494c..05011e48 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-exposures.md
@@ -42,4 +42,3 @@ Declared in `models/exposures.yml`. All exposures are type `dashboard` with matu
- **Owner:** Growth (growth@orbit-demo.example.com)
- **Depends on:** `mart_account_activity`
- **Description:** Activation policy comparison around the January 2026 workflow update.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md
index 11aac427..41179bc2 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-dbt-project-overview.md
@@ -22,10 +22,10 @@ sl_refs:
# Orbit dbt Project Overview
-**Project name:** `kaelio_demo`
-**dbt version:** 1.0.0
-**Profile target:** Postgres (`orbit_analytics` schema, `kaelio_demo` database)
-**Raw source schema:** `orbit_raw`
+**Project name:** `kaelio_demo`
+**dbt version:** 1.0.0
+**Profile target:** Postgres (`orbit_analytics` schema, `kaelio_demo` database)
+**Raw source schema:** `orbit_raw`
**Analytics schema:** `orbit_analytics` (all models materialised as views by default)
## Model Layers
@@ -52,4 +52,3 @@ sl_refs:
## Raw Source Tables (`orbit_raw` schema)
accounts, account_hierarchy, plans, contracts, subscriptions, contract_discount_terms, arr_movements, invoices, invoice_line_items, refunds, plan_segment_mapping, users, activation_events, sessions, purchase_requests, approval_events, suppliers, supplier_onboarding_events, purchase_orders, support_tickets, account_owners.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md
index 29ba3392..74a936f6 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-activity.md
@@ -20,7 +20,7 @@ tables:
-**Table:** `orbit_analytics.mart_account_activity`
+**Table:** `orbit_analytics.mart_account_activity`
**Grain:** one row per `policy_change_date`
## Columns
@@ -47,4 +47,3 @@ tables:
- The January 2026 activation policy change (`policy_change_date = 2026-01-15`) is the primary boundary. `policy_version` in upstream events splits into `pre_2026_01_15` and `post_2026_01_15` cohorts.
- Rates are ratios (0–1); multiply by 100 for percentage display.
- See [orbit-activation-policy-change-jan-2026](orbit-activation-policy-change-jan-2026) for full policy context.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md
index d23ee684..04085359 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-account-segments.md
@@ -19,7 +19,7 @@ tables:
-**Table:** `orbit_analytics.mart_account_segments`
+**Table:** `orbit_analytics.mart_account_segments`
**Grain:** one row per `account_id`
## Columns
@@ -53,4 +53,3 @@ tables:
- `normalized_plan_code` maps `pro_plus` → `growth`. Always use `normalized_plan_code` for plan-based reporting. See [orbit-plan-segment-normalization](orbit-plan-segment-normalization).
- `segment` is derived from `canonical_plan_code × size_band` via `stg_plan_segment_mapping`.
- `contract_arr_cents` is the contract-first ARR value. See [orbit-arr-contract-first-definition](orbit-arr-contract-first-definition).
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md
index f1231a30..5b3db7dd 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-arr-daily.md
@@ -18,7 +18,7 @@ tables:
-**Table:** `orbit_analytics.mart_arr_daily`
+**Table:** `orbit_analytics.mart_arr_daily`
**Grain:** one row per `metric_date`
## Columns
@@ -44,4 +44,3 @@ tables:
- ARR is calculated contract-first: active contract ARR takes precedence over subscription ARR for any covered period. See [orbit-arr-contract-first-definition](orbit-arr-contract-first-definition).
- `display` is a formatted label for UI rendering; use `arr_cents` for all arithmetic.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md
index 9b423e6d..288c8201 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-nrr-quarterly.md
@@ -20,7 +20,7 @@ tables:
-**Table:** `orbit_analytics.mart_nrr_quarterly`
+**Table:** `orbit_analytics.mart_nrr_quarterly`
**Grain:** one row per `quarter_label` × `segment`
## Columns
@@ -53,4 +53,3 @@ tables:
- `net_revenue_retention` is a ratio, not a percentage. Multiply by 100 for display.
- Contraction includes discount expirations (classified as contraction, not churn). See [orbit-nrr-discount-expiration-treatment](orbit-nrr-discount-expiration-treatment).
- Enterprise is the primary executive reporting segment.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md
index 0b31edae..ab3de364 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-procurement-activity.md
@@ -18,7 +18,7 @@ tables:
-**Table:** `orbit_analytics.mart_procurement_activity`
+**Table:** `orbit_analytics.mart_procurement_activity`
**Grain:** one row per `week_start_date` × `contract_arr_threshold_cents`
## Columns
@@ -45,4 +45,3 @@ tables:
- `active_requesters` counts non-internal, non-test requesters on large active contracts. See [orbit-procurement-qualifying-actions](orbit-procurement-qualifying-actions).
- The standard threshold is `contract_arr_threshold_cents = 20000000` ($200k ARR).
- Always filter by `contract_arr_threshold_cents` — the table contains rows for multiple threshold values.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md
index d5021f0a..7c22cf16 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-retention-movement-breakout.md
@@ -19,7 +19,7 @@ tables:
-**Table:** `orbit_analytics.mart_retention_movement_breakout`
+**Table:** `orbit_analytics.mart_retention_movement_breakout`
**Grain:** one row per `quarter_label` × `segment` × `movement_type` × `movement_reason`
## Columns
@@ -53,4 +53,3 @@ tables:
- Contraction includes discount expirations, classified as contraction (not churn), tracked via `movement_reason`. See [orbit-nrr-discount-expiration-treatment](orbit-nrr-discount-expiration-treatment).
- This table is the row-level source for `mart_nrr_quarterly` aggregations.
- Only one of `expansion_arr_cents`, `contraction_arr_cents`, `churned_arr_cents` is non-zero per row.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md
index 6a088de0..8deb5ffe 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-mart-revenue-daily.md
@@ -20,7 +20,7 @@ tables:
-**Table:** `orbit_analytics.mart_revenue_daily`
+**Table:** `orbit_analytics.mart_revenue_daily`
**Grain:** one row per `revenue_date`
## Columns
@@ -54,4 +54,3 @@ tables:
- `reconciliation_check` must be `true` on every row. Any `false` row indicates a data quality issue.
- Gross-to-net reconciliation: gross revenue − credits − refunds = net revenue. See [orbit-revenue-gross-to-net-reconciliation](orbit-revenue-gross-to-net-reconciliation).
- All amounts are in cents; divide by 100 for USD, by 100,000,000 for $M.
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md
index d94ba88b..28055b9b 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-metabase-sql-library-patterns.md
@@ -69,4 +69,3 @@ Card 48 is the canonical reference; card 55 is a filtered variant for large-cont
| 53 | Enterprise NRR quarter breakout | mart_nrr_quarterly | 0 |
| 54 | February credits drilldown | mart_revenue_daily | 0 |
| 55 | Large contract requesters | mart_account_segments | 0 |
-
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md
index 4af79d78..0b966d8c 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-nrr-discount-expiration-treatment.md
@@ -15,9 +15,9 @@ sl_refs:
# NRR — Discount Expiration Treatment
-**Governed metric key:** `net_revenue_retention`
-**Owner team:** analytics
-**Notion:** `notion://notion_page_retention_policy_current#nrr-definition` and `#discount-expiration-treatment`
+**Governed metric key:** `net_revenue_retention`
+**Owner team:** analytics
+**Notion:** `notion://notion_page_retention_policy_current#nrr-definition` and `#discount-expiration-treatment`
**Sources:** `mart_nrr_quarterly`, `mart_retention_movement_breakout`
## NRR Definition
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md
index aeab9be3..580c1fa1 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-plan-segment-normalization.md
@@ -14,9 +14,9 @@ sl_refs:
# Plan & Segment Normalization
-**Governed metric key:** `segment`
-**Owner team:** sales_ops
-**Notion:** `notion://notion_page_sales_ops_segmentation#growth-plan-normalization`
+**Governed metric key:** `segment`
+**Owner team:** sales_ops
+**Notion:** `notion://notion_page_sales_ops_segmentation#growth-plan-normalization`
**Sources:** `mart_account_segments`, `stg_plan_segment_mapping`, `stg_plans`
## Canonical Plan Codes
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md
index 87cc64c5..08126dd5 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-procurement-qualifying-actions.md
@@ -14,9 +14,9 @@ sl_refs:
# Procurement — Qualifying Actions & Weekly Active Requesters
-**Governed metric key:** `weekly_active_requesters`
-**Owner team:** product
-**Notion:** `notion://notion_page_procurement_instrumentation#qualifying-procurement-actions`
+**Governed metric key:** `weekly_active_requesters`
+**Owner team:** product
+**Notion:** `notion://notion_page_procurement_instrumentation#qualifying-procurement-actions`
**Sources:** `mart_procurement_activity`, `int_procurement_qualifying_actions`
## Qualifying Action Definition
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md b/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md
index 7de4138b..65004331 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/orbit-revenue-gross-to-net-reconciliation.md
@@ -14,9 +14,9 @@ sl_refs:
# Revenue — Gross-to-Net Reconciliation
-**Governed metric key:** `net_revenue`
-**Owner team:** finance
-**Notion:** `notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation`
+**Governed metric key:** `net_revenue`
+**Owner team:** finance
+**Notion:** `notion://notion_page_revenue_reporting_policy#gross-to-net-reconciliation`
**Source:** `mart_revenue_daily` (grain: `revenue_date`)
## Formula
diff --git a/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md b/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md
index 13eb139f..d547d026 100644
--- a/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md
+++ b/packages/cli/assets/demo/orbit/knowledge/global/sales-ops-cs-handoff-process.md
@@ -14,7 +14,7 @@ refs:
## Sales Ops → Customer Success Implementation Handoff
-**Source:** Notion — People & Operating Norms, last edited 2026-05-07
+**Source:** Notion — People & Operating Norms, last edited 2026-05-07
**Owner:** Sales Ops (sender), Customer Success (receiver)
---
diff --git a/packages/cli/src/commands/connection-metabase-setup.test.ts b/packages/cli/src/commands/connection-metabase-setup.test.ts
index cd94565a..cf7308d7 100644
--- a/packages/cli/src/commands/connection-metabase-setup.test.ts
+++ b/packages/cli/src/commands/connection-metabase-setup.test.ts
@@ -138,7 +138,7 @@ function makeIo(options: { isTTY?: boolean; stdinIsTTY?: boolean } = {}) {
describe('runKtxConnectionMetabaseSetup', () => {
const fakeMetabaseCredential = 'mb_example';
const existingMetabaseCredential = 'mb_existing';
- const fakeAdminCredential = 'pw';
+ const fakeAdminCredential = 'admin-secret-value-123';
let tempDir: string;
let projectDir: string;
diff --git a/packages/cli/src/commands/runtime-commands.ts b/packages/cli/src/commands/runtime-commands.ts
index 8f478658..3ce7d9ba 100644
--- a/packages/cli/src/commands/runtime-commands.ts
+++ b/packages/cli/src/commands/runtime-commands.ts
@@ -53,10 +53,12 @@ export function registerRuntimeCommands(program: Command, context: KtxCliCommand
runtime
.command('stop')
.description('Stop the KTX-managed Python HTTP daemon')
- .action(async () => {
+ .option('--all', 'Stop all KTX daemon processes recorded or discoverable on this machine', false)
+ .action(async (options: { all?: boolean }) => {
await runRuntimeArgs(context, {
command: 'stop',
cliVersion: context.packageInfo.version,
+ all: options.all === true,
});
});
diff --git a/packages/cli/src/connection.test.ts b/packages/cli/src/connection.test.ts
index ae593805..04c73cf1 100644
--- a/packages/cli/src/connection.test.ts
+++ b/packages/cli/src/connection.test.ts
@@ -477,7 +477,7 @@ describe('runKtxConnection', () => {
force: false,
allowLiteralCredentials: false,
notion: {
- authTokenRef: 'env:NOTION_AUTH_TOKEN',
+ authTokenRef: 'env:NOTION_TOKEN',
crawlMode: 'all_accessible',
rootPageIds: [],
rootDatabaseIds: [],
@@ -493,7 +493,7 @@ describe('runKtxConnection', () => {
const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(yaml).toContain('driver: notion');
- expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN');
+ expect(yaml).toContain('auth_token_ref: env:NOTION_TOKEN');
expect(yaml).toContain('crawl_mode: all_accessible');
expect(yaml).toContain('max_pages_per_run: 50');
expect(yaml).not.toContain('ntn_');
@@ -516,7 +516,7 @@ describe('runKtxConnection', () => {
force: false,
allowLiteralCredentials: false,
notion: {
- authTokenRef: 'env:NOTION_AUTH_TOKEN',
+ authTokenRef: 'env:NOTION_TOKEN',
crawlMode: 'all_accessible',
rootPageIds: [],
rootDatabaseIds: ['database-1'],
diff --git a/packages/cli/src/demo.test.ts b/packages/cli/src/demo.test.ts
index 0cedba99..0b053ee6 100644
--- a/packages/cli/src/demo.test.ts
+++ b/packages/cli/src/demo.test.ts
@@ -11,6 +11,9 @@ import type { renderMemoryFlowTui } from './memory-flow-tui.js';
import { KTX_NEXT_STEP_COMMANDS } from './next-steps.js';
import { resetVizFallbackWarningsForTest } from './viz-fallback.js';
+const SEEDED_DEMO_SEMANTIC_SOURCE_COUNT = 46;
+const SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT = 28;
+
function makeIo(options: { isTTY?: boolean; columns?: number; rawMode?: boolean } = {}) {
let stdout = '';
let stderr = '';
@@ -336,8 +339,14 @@ describe('runKtxDemo', () => {
notion: { pageCount: 8 },
},
generatedOutputs: {
- semanticLayer: { manifestSourceCount: 6, fileCount: 6 },
- knowledge: { manifestPageCount: 10, fileCount: 10 },
+ semanticLayer: {
+ manifestSourceCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
+ fileCount: SEEDED_DEMO_SEMANTIC_SOURCE_COUNT,
+ },
+ knowledge: {
+ manifestPageCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
+ fileCount: SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT,
+ },
links: { manifestLinkCount: 23, linkCount: 23 },
reports: { primaryPath: 'reports/seeded-demo-report.json', fileCount: 1 },
},
@@ -636,10 +645,16 @@ describe('runKtxDemo', () => {
).resolves.toBe(0);
expect(seededIo.stdout()).toContain('Status: ready');
- expect(seededIo.stdout()).toContain('Semantic-layer sources: 6 manifest, 6 files');
- expect(seededIo.stdout()).toContain('Knowledge pages: 10 manifest, 10 files');
+ expect(seededIo.stdout()).toContain(
+ `Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} files`,
+ );
+ expect(seededIo.stdout()).toContain(
+ `Knowledge pages: ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} manifest, ${SEEDED_DEMO_KNOWLEDGE_PAGE_COUNT} files`,
+ );
expect(seededIo.stdout()).not.toContain('Status: corrupt');
- expect(seededIo.stdout()).not.toContain('Semantic-layer sources: 6 manifest, 0 files');
+ expect(seededIo.stdout()).not.toContain(
+ `Semantic-layer sources: ${SEEDED_DEMO_SEMANTIC_SOURCE_COUNT} manifest, 0 files`,
+ );
});
it('fails corrupted demo projects in no-input mode with reset guidance', async () => {
diff --git a/packages/cli/src/index.test.ts b/packages/cli/src/index.test.ts
index 8ee74016..87a0089f 100644
--- a/packages/cli/src/index.test.ts
+++ b/packages/cli/src/index.test.ts
@@ -144,6 +144,7 @@ describe('runKtxCli', () => {
const installIo = makeIo();
const startIo = makeIo();
const stopIo = makeIo();
+ const stopAllIo = makeIo();
const statusIo = makeIo();
const doctorIo = makeIo();
const pruneIo = makeIo();
@@ -157,6 +158,7 @@ describe('runKtxCli', () => {
runKtxCli(['runtime', 'start', '--feature', 'local-embeddings', '--force'], startIo.io, { runtime }),
).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'stop'], stopIo.io, { runtime })).resolves.toBe(0);
+ await expect(runKtxCli(['runtime', 'stop', '--all'], stopAllIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'status', '--json'], statusIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'doctor'], doctorIo.io, { runtime })).resolves.toBe(0);
await expect(runKtxCli(['runtime', 'prune', '--dry-run'], pruneIo.io, { runtime })).resolves.toBe(0);
@@ -186,11 +188,21 @@ describe('runKtxCli', () => {
{
command: 'stop',
cliVersion: '0.0.0-private',
+ all: false,
},
stopIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
4,
+ {
+ command: 'stop',
+ cliVersion: '0.0.0-private',
+ all: true,
+ },
+ stopAllIo.io,
+ );
+ expect(runtime).toHaveBeenNthCalledWith(
+ 5,
{
command: 'status',
cliVersion: '0.0.0-private',
@@ -199,7 +211,7 @@ describe('runKtxCli', () => {
statusIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
- 5,
+ 6,
{
command: 'doctor',
cliVersion: '0.0.0-private',
@@ -208,7 +220,7 @@ describe('runKtxCli', () => {
doctorIo.io,
);
expect(runtime).toHaveBeenNthCalledWith(
- 6,
+ 7,
{
command: 'prune',
cliVersion: '0.0.0-private',
@@ -219,6 +231,17 @@ describe('runKtxCli', () => {
);
});
+ it('documents runtime stop all in command help', async () => {
+ const testIo = makeIo();
+
+ await expect(runKtxCli(['runtime', 'stop', '--help'], testIo.io)).resolves.toBe(0);
+
+ expect(testIo.stdout()).toContain('--all');
+ expect(testIo.stdout()).toContain('Stop all KTX daemon processes recorded or discoverable');
+ expect(testIo.stdout()).toContain('on this machine');
+ expect(testIo.stderr()).toBe('');
+ });
+
it('routes sl query managed runtime install policies', async () => {
const sl = vi.fn(async () => 0);
@@ -1982,7 +2005,7 @@ describe('runKtxCli', () => {
'--project-dir',
tempDir,
'--token-env',
- 'NOTION_AUTH_TOKEN',
+ 'NOTION_TOKEN',
'--crawl-mode',
'selected_roots',
'--root-page-id',
@@ -2009,7 +2032,7 @@ describe('runKtxCli', () => {
force: false,
allowLiteralCredentials: false,
notion: {
- authTokenRef: 'env:NOTION_AUTH_TOKEN',
+ authTokenRef: 'env:NOTION_TOKEN',
crawlMode: 'selected_roots',
rootPageIds: ['page-1'],
rootDatabaseIds: ['database-1'],
diff --git a/packages/cli/src/index.ts b/packages/cli/src/index.ts
index 96fbbeec..de906ece 100644
--- a/packages/cli/src/index.ts
+++ b/packages/cli/src/index.ts
@@ -47,13 +47,18 @@ export { runKtxRuntime, type KtxRuntimeArgs, type KtxRuntimeDeps } from './runti
export {
allocateDaemonPort,
readManagedPythonDaemonStatus,
+ stopAllManagedPythonDaemons,
startManagedPythonDaemon,
stopManagedPythonDaemon,
} from './managed-python-daemon.js';
export type {
+ ManagedPythonDaemonProcessInfo,
ManagedPythonDaemonStartResult,
ManagedPythonDaemonState,
ManagedPythonDaemonStatus,
+ ManagedPythonDaemonStopAllEntry,
+ ManagedPythonDaemonStopAllFailure,
+ ManagedPythonDaemonStopAllResult,
ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
export {
diff --git a/packages/cli/src/managed-python-daemon.test.ts b/packages/cli/src/managed-python-daemon.test.ts
index 4e7af22c..ffa69972 100644
--- a/packages/cli/src/managed-python-daemon.test.ts
+++ b/packages/cli/src/managed-python-daemon.test.ts
@@ -5,9 +5,11 @@ import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
import {
readManagedPythonDaemonStatus,
startManagedPythonDaemon,
+ stopAllManagedPythonDaemons,
stopManagedPythonDaemon,
type ManagedPythonDaemonChild,
type ManagedPythonDaemonFetch,
+ type ManagedPythonDaemonProcessInfo,
type ManagedPythonDaemonSpawn,
type ManagedPythonDaemonState,
} from './managed-python-daemon.js';
@@ -105,6 +107,24 @@ function runningState(root: string, overrides: Partial
};
}
+function daemonStatePath(root: string, version: string): string {
+ return join(root, 'runtime', version, 'daemon.json');
+}
+
+function runningStateForVersion(
+ root: string,
+ version: string,
+ overrides: Partial = {},
+): ManagedPythonDaemonState {
+ return {
+ ...runningState(root),
+ version,
+ stdoutLog: join(root, 'runtime', version, 'daemon.stdout.log'),
+ stderrLog: join(root, 'runtime', version, 'daemon.stderr.log'),
+ ...overrides,
+ };
+}
+
describe('managed Python daemon lifecycle', () => {
let tempDir: string;
@@ -271,4 +291,138 @@ describe('managed Python daemon lifecycle', () => {
expect(killProcess).toHaveBeenCalledWith(4242);
await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
});
+
+ it('stops all recorded daemon states across runtime versions and removes state files', async () => {
+ await mkdir(join(tempDir, 'runtime', '0.1.0'), { recursive: true });
+ await mkdir(join(tempDir, 'runtime', '0.2.0'), { recursive: true });
+ await writeFile(
+ daemonStatePath(tempDir, '0.1.0'),
+ `${JSON.stringify(runningStateForVersion(tempDir, '0.1.0', { pid: 1111, port: 61111 }), null, 2)}\n`,
+ );
+ await writeFile(
+ daemonStatePath(tempDir, '0.2.0'),
+ `${JSON.stringify(runningStateForVersion(tempDir, '0.2.0', { pid: 2222, port: 62222 }), null, 2)}\n`,
+ );
+ const alive = new Set([1111, 2222]);
+ const killProcess = vi.fn((pid: number) => {
+ alive.delete(pid);
+ });
+
+ const result = await stopAllManagedPythonDaemons({
+ cliVersion: '0.2.0',
+ runtimeRoot: join(tempDir, 'runtime'),
+ listProcesses: vi.fn(async () => []),
+ processAlive: vi.fn((pid) => alive.has(pid)),
+ killProcess,
+ stopGraceMs: 0,
+ });
+
+ expect(result.failed).toHaveLength(0);
+ expect(result.stopped.map((entry) => entry.pid).sort()).toEqual([1111, 2222]);
+ expect(killProcess).toHaveBeenCalledWith(1111, 'SIGTERM');
+ expect(killProcess).toHaveBeenCalledWith(2222, 'SIGTERM');
+ await expect(readFile(daemonStatePath(tempDir, '0.1.0'), 'utf8')).rejects.toThrow();
+ await expect(readFile(daemonStatePath(tempDir, '0.2.0'), 'utf8')).rejects.toThrow();
+ });
+
+ it('removes stale state when the recorded daemon process is no longer alive', async () => {
+ await mkdir(layout(tempDir).versionDir, { recursive: true });
+ await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
+
+ const result = await stopAllManagedPythonDaemons({
+ cliVersion: '0.2.0',
+ runtimeRoot: join(tempDir, 'runtime'),
+ listProcesses: vi.fn(async () => []),
+ processAlive: vi.fn(() => false),
+ killProcess: vi.fn(),
+ stopGraceMs: 0,
+ });
+
+ expect(result.stopped).toHaveLength(0);
+ expect(result.stale.map((entry) => entry.pid)).toEqual([4242]);
+ await expect(readFile(layout(tempDir).daemonStatePath, 'utf8')).rejects.toThrow();
+ });
+
+ it('deduplicates a daemon found by state and process scan, preferring state metadata', async () => {
+ await mkdir(layout(tempDir).versionDir, { recursive: true });
+ await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
+ const alive = new Set([4242]);
+ const killProcess = vi.fn((pid: number) => {
+ alive.delete(pid);
+ });
+
+ const result = await stopAllManagedPythonDaemons({
+ cliVersion: '0.2.0',
+ runtimeRoot: join(tempDir, 'runtime'),
+ listProcesses: vi.fn(async (): Promise => [
+ { pid: 4242, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 61234' },
+ ]),
+ processAlive: vi.fn((pid) => alive.has(pid)),
+ killProcess,
+ stopGraceMs: 0,
+ });
+
+ expect(result.stopped).toHaveLength(1);
+ expect(result.stopped[0]).toMatchObject({
+ pid: 4242,
+ source: 'state',
+ url: 'http://127.0.0.1:58731',
+ });
+ expect(killProcess).toHaveBeenCalledTimes(1);
+ });
+
+ it('stops unrecorded ktx-daemon serve-http processes from process scan results', async () => {
+ const alive = new Set([3333, 5555]);
+ const killProcess = vi.fn((pid: number) => {
+ alive.delete(pid);
+ });
+
+ const result = await stopAllManagedPythonDaemons({
+ cliVersion: '0.2.0',
+ runtimeRoot: join(tempDir, 'runtime'),
+ listProcesses: vi.fn(async (): Promise => [
+ { pid: 3333, command: 'uv run ktx-daemon serve-http --host 127.0.0.1 --port 8765' },
+ { pid: 4444, command: 'node server.js --port 8765' },
+ { pid: 5555, command: 'grep ktx-daemon serve-http --port 8765' },
+ ]),
+ processAlive: vi.fn((pid) => alive.has(pid)),
+ killProcess,
+ stopGraceMs: 0,
+ });
+
+ expect(result.failed).toHaveLength(0);
+ expect(result.stopped).toEqual([
+ expect.objectContaining({
+ pid: 3333,
+ source: 'process',
+ url: 'http://127.0.0.1:8765',
+ }),
+ ]);
+ expect(killProcess).toHaveBeenCalledWith(3333, 'SIGTERM');
+ expect(killProcess).not.toHaveBeenCalledWith(4444, expect.anything());
+ expect(killProcess).not.toHaveBeenCalledWith(5555, expect.anything());
+ });
+
+ it('reports a failed stop when TERM and KILL leave a daemon running', async () => {
+ await mkdir(layout(tempDir).versionDir, { recursive: true });
+ await writeFile(layout(tempDir).daemonStatePath, `${JSON.stringify(runningState(tempDir), null, 2)}\n`);
+
+ const result = await stopAllManagedPythonDaemons({
+ cliVersion: '0.2.0',
+ runtimeRoot: join(tempDir, 'runtime'),
+ listProcesses: vi.fn(async () => []),
+ processAlive: vi.fn(() => true),
+ killProcess: vi.fn(),
+ stopGraceMs: 0,
+ });
+
+ expect(result.stopped).toHaveLength(0);
+ expect(result.failed).toEqual([
+ expect.objectContaining({
+ pid: 4242,
+ detail: 'Process still running after SIGKILL',
+ }),
+ ]);
+ expect(await readFile(layout(tempDir).daemonStatePath, 'utf8')).toContain('"pid": 4242');
+ });
});
diff --git a/packages/cli/src/managed-python-daemon.ts b/packages/cli/src/managed-python-daemon.ts
index 2caf9182..b99de581 100644
--- a/packages/cli/src/managed-python-daemon.ts
+++ b/packages/cli/src/managed-python-daemon.ts
@@ -1,7 +1,9 @@
-import { spawn } from 'node:child_process';
-import { mkdir, open, readFile, rm, writeFile } from 'node:fs/promises';
+import { execFile, spawn } from 'node:child_process';
+import { mkdir, open, readdir, readFile, rm, writeFile } from 'node:fs/promises';
import { createServer } from 'node:net';
+import { join } from 'node:path';
import { setTimeout as delay } from 'node:timers/promises';
+import { promisify } from 'node:util';
import { z } from 'zod';
import {
installManagedPythonRuntime,
@@ -44,6 +46,35 @@ export interface ManagedPythonDaemonStopResult {
state?: ManagedPythonDaemonState;
}
+export interface ManagedPythonDaemonProcessInfo {
+ pid: number;
+ command: string;
+}
+
+export type ManagedPythonDaemonStopAllSource = 'state' | 'process';
+
+export interface ManagedPythonDaemonStopAllEntry {
+ pid: number;
+ source: ManagedPythonDaemonStopAllSource;
+ url?: string;
+ health?: 'healthy' | 'unreachable';
+ version?: string;
+ command?: string;
+ statePaths: string[];
+}
+
+export interface ManagedPythonDaemonStopAllFailure extends ManagedPythonDaemonStopAllEntry {
+ detail: string;
+}
+
+export interface ManagedPythonDaemonStopAllResult {
+ runtimeRoot: string;
+ stopped: ManagedPythonDaemonStopAllEntry[];
+ stale: ManagedPythonDaemonStopAllEntry[];
+ failed: ManagedPythonDaemonStopAllFailure[];
+ scanErrors: string[];
+}
+
export interface ManagedPythonDaemonChild {
pid?: number;
unref(): void;
@@ -68,6 +99,8 @@ export type ManagedPythonDaemonFetch = (
text(): Promise;
}>;
+export type ManagedPythonDaemonKillProcess = (pid: number, signal?: NodeJS.Signals) => void;
+
export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLayoutOptions {
features: KtxRuntimeFeature[];
force?: boolean;
@@ -76,7 +109,7 @@ export interface ManagedPythonDaemonStartOptions extends ManagedPythonRuntimeLay
fetch?: ManagedPythonDaemonFetch;
allocatePort?: () => Promise;
processAlive?: (pid: number) => boolean;
- killProcess?: (pid: number) => void;
+ killProcess?: ManagedPythonDaemonKillProcess;
now?: () => Date;
startupTimeoutMs?: number;
pollIntervalMs?: number;
@@ -89,9 +122,20 @@ export interface ManagedPythonDaemonStatusOptions extends ManagedPythonRuntimeLa
export interface ManagedPythonDaemonStopOptions extends ManagedPythonRuntimeLayoutOptions {
processAlive?: (pid: number) => boolean;
- killProcess?: (pid: number) => void;
+ killProcess?: ManagedPythonDaemonKillProcess;
}
+export interface ManagedPythonDaemonStopAllOptions extends ManagedPythonRuntimeLayoutOptions {
+ listProcesses?: () => Promise;
+ processAlive?: (pid: number) => boolean;
+ killProcess?: ManagedPythonDaemonKillProcess;
+ stopGraceMs?: number;
+ pollIntervalMs?: number;
+ healthProbeMs?: number;
+}
+
+const execFileAsync = promisify(execFile);
+
const daemonStateSchema = z.object({
schemaVersion: z.literal(1),
pid: z.number().int().positive(),
@@ -126,9 +170,9 @@ function defaultProcessAlive(pid: number): boolean {
}
}
-function defaultKillProcess(pid: number): void {
+function defaultKillProcess(pid: number, signal: NodeJS.Signals = 'SIGTERM'): void {
try {
- process.kill(pid, 'SIGTERM');
+ process.kill(pid, signal);
} catch (error) {
const code = (error as { code?: unknown }).code;
if (code !== 'ESRCH') {
@@ -293,7 +337,7 @@ async function stopRecordedDaemon(input: {
layout: ManagedPythonRuntimeLayout;
state: ManagedPythonDaemonState;
processAlive: (pid: number) => boolean;
- killProcess: (pid: number) => void;
+ killProcess: ManagedPythonDaemonKillProcess;
}): Promise {
if (input.processAlive(input.state.pid)) {
input.killProcess(input.state.pid);
@@ -301,6 +345,323 @@ async function stopRecordedDaemon(input: {
await removeState(input.layout);
}
+function runtimeRootForStopAll(options: ManagedPythonRuntimeLayoutOptions): string {
+ return managedPythonRuntimeLayout(options).runtimeRoot;
+}
+
+async function removeStatePaths(paths: string[]): Promise {
+ await Promise.all([...new Set(paths)].map((path) => rm(path, { force: true })));
+}
+
+interface ManagedPythonDaemonStopCandidate {
+ pid: number;
+ source: ManagedPythonDaemonStopAllSource;
+ host?: string;
+ port?: number;
+ version?: string;
+ command?: string;
+ statePaths: string[];
+}
+
+function candidateUrl(candidate: ManagedPythonDaemonStopCandidate): string | undefined {
+ if (!candidate.host || !candidate.port) {
+ return undefined;
+ }
+ return `http://${candidate.host}:${candidate.port}`;
+}
+
+function candidateEntry(candidate: ManagedPythonDaemonStopCandidate): ManagedPythonDaemonStopAllEntry {
+ return {
+ pid: candidate.pid,
+ source: candidate.source,
+ ...(candidateUrl(candidate) ? { url: candidateUrl(candidate) } : {}),
+ ...(candidate.version ? { version: candidate.version } : {}),
+ ...(candidate.command ? { command: candidate.command } : {}),
+ statePaths: [...candidate.statePaths],
+ };
+}
+
+async function probeCandidateHealth(
+ candidate: ManagedPythonDaemonStopCandidate,
+ timeoutMs: number,
+): Promise<'healthy' | 'unreachable' | undefined> {
+ const url = candidateUrl(candidate);
+ if (!url) {
+ return undefined;
+ }
+ const controller = new AbortController();
+ const timeout = setTimeout(() => {
+ controller.abort();
+ }, timeoutMs);
+ try {
+ const response = await fetch(`${url}/health`, { signal: controller.signal });
+ if (!response.ok) {
+ return 'unreachable';
+ }
+ const body = (await response.json()) as unknown;
+ if (!body || typeof body !== 'object' || Array.isArray(body)) {
+ return 'unreachable';
+ }
+ return (body as Record).status === 'healthy' ? 'healthy' : 'unreachable';
+ } catch {
+ return 'unreachable';
+ } finally {
+ clearTimeout(timeout);
+ }
+}
+
+async function readStateCandidates(runtimeRoot: string): Promise {
+ let entries;
+ try {
+ entries = await readdir(runtimeRoot, { withFileTypes: true });
+ } catch (error) {
+ const code = (error as { code?: unknown }).code;
+ if (code === 'ENOENT') {
+ return [];
+ }
+ throw error;
+ }
+ const candidates: ManagedPythonDaemonStopCandidate[] = [];
+ for (const entry of entries) {
+ if (!entry.isDirectory()) {
+ continue;
+ }
+ const statePath = join(runtimeRoot, entry.name, 'daemon.json');
+ let state: ManagedPythonDaemonState | undefined;
+ try {
+ state = await readState(statePath);
+ } catch {
+ continue;
+ }
+ if (!state) {
+ continue;
+ }
+ candidates.push({
+ pid: state.pid,
+ source: 'state',
+ host: state.host,
+ port: state.port,
+ version: state.version,
+ statePaths: [statePath],
+ });
+ }
+ return candidates;
+}
+
+function tokenizeCommand(command: string): string[] {
+ const tokens: string[] = [];
+ for (const match of command.matchAll(/"([^"]*)"|'([^']*)'|(\S+)/g)) {
+ tokens.push(match[1] ?? match[2] ?? match[3] ?? '');
+ }
+ return tokens;
+}
+
+function executableName(token: string): string {
+ return token.split(/[\\/]/).at(-1) ?? token;
+}
+
+function isKtxDaemonExecutable(token: string): boolean {
+ return executableName(token) === 'ktx-daemon' || executableName(token) === 'ktx-daemon.exe';
+}
+
+function normalizedExecutableName(token: string): string {
+ return executableName(token).replace(/\.exe$/i, '').toLowerCase();
+}
+
+function hasUvRunPrefix(tokens: string[], daemonIndex: number): boolean {
+ return normalizedExecutableName(tokens[0] ?? '') === 'uv' && tokens.slice(1, daemonIndex).includes('run');
+}
+
+function isPythonExecutable(token: string): boolean {
+ const name = normalizedExecutableName(token);
+ return name === 'python' || name === 'python3';
+}
+
+function hasPythonModulePrefix(tokens: string[], moduleFlagIndex: number): boolean {
+ if (moduleFlagIndex === 1 && isPythonExecutable(tokens[0] ?? '')) {
+ return true;
+ }
+ return (
+ normalizedExecutableName(tokens[0] ?? '') === 'uv' &&
+ tokens.slice(1, moduleFlagIndex).includes('run') &&
+ tokens.some((token, index) => index < moduleFlagIndex && isPythonExecutable(token))
+ );
+}
+
+function isKtxDaemonServeHttp(tokens: string[]): boolean {
+ for (let index = 0; index < tokens.length; index += 1) {
+ if (
+ isKtxDaemonExecutable(tokens[index] ?? '') &&
+ tokens[index + 1] === 'serve-http' &&
+ (index === 0 || hasUvRunPrefix(tokens, index))
+ ) {
+ return true;
+ }
+ if (
+ tokens[index] === '-m' &&
+ tokens[index + 1] === 'ktx_daemon' &&
+ tokens[index + 2] === 'serve-http' &&
+ hasPythonModulePrefix(tokens, index)
+ ) {
+ return true;
+ }
+ }
+ return false;
+}
+
+function parseCommandOption(tokens: string[], option: string): string | undefined {
+ for (let index = 0; index < tokens.length; index += 1) {
+ const token = tokens[index];
+ if (token === option) {
+ return tokens[index + 1];
+ }
+ if (token?.startsWith(`${option}=`)) {
+ return token.slice(option.length + 1);
+ }
+ }
+ return undefined;
+}
+
+function processCandidate(processInfo: ManagedPythonDaemonProcessInfo): ManagedPythonDaemonStopCandidate | undefined {
+ const tokens = tokenizeCommand(processInfo.command);
+ if (!isKtxDaemonServeHttp(tokens)) {
+ return undefined;
+ }
+ const host = parseCommandOption(tokens, '--host') ?? '127.0.0.1';
+ const rawPort = parseCommandOption(tokens, '--port');
+ const parsedPort = rawPort ? Number.parseInt(rawPort, 10) : 8765;
+ const port = Number.isInteger(parsedPort) && parsedPort >= 1 && parsedPort <= 65535 ? parsedPort : 8765;
+ return {
+ pid: processInfo.pid,
+ source: 'process',
+ host,
+ port,
+ command: processInfo.command,
+ statePaths: [],
+ };
+}
+
+function mergeCandidates(candidates: ManagedPythonDaemonStopCandidate[]): ManagedPythonDaemonStopCandidate[] {
+ const byPid = new Map();
+ for (const candidate of candidates) {
+ const existing = byPid.get(candidate.pid);
+ if (!existing) {
+ byPid.set(candidate.pid, { ...candidate, statePaths: [...candidate.statePaths] });
+ continue;
+ }
+ existing.statePaths.push(...candidate.statePaths);
+ if (existing.source === 'process' && candidate.source === 'state') {
+ byPid.set(candidate.pid, {
+ ...candidate,
+ statePaths: [...new Set([...existing.statePaths, ...candidate.statePaths])],
+ });
+ } else {
+ existing.statePaths = [...new Set(existing.statePaths)];
+ }
+ }
+ return [...byPid.values()].sort((left, right) => left.pid - right.pid);
+}
+
+function parsePosixProcessList(output: string): ManagedPythonDaemonProcessInfo[] {
+ const processes: ManagedPythonDaemonProcessInfo[] = [];
+ for (const line of output.split(/\r?\n/)) {
+ const match = line.match(/^\s*(\d+)\s+(.+)$/);
+ if (!match) {
+ continue;
+ }
+ processes.push({ pid: Number.parseInt(match[1], 10), command: match[2] });
+ }
+ return processes;
+}
+
+function parseWindowsProcessList(output: string): ManagedPythonDaemonProcessInfo[] {
+ if (!output.trim()) {
+ return [];
+ }
+ const parsed = JSON.parse(output) as unknown;
+ const records = Array.isArray(parsed) ? parsed : [parsed];
+ const processes: ManagedPythonDaemonProcessInfo[] = [];
+ for (const record of records) {
+ if (!record || typeof record !== 'object') {
+ continue;
+ }
+ const value = record as Record;
+ const pid = value.ProcessId;
+ const command = value.CommandLine;
+ if (typeof pid === 'number' && typeof command === 'string' && command.length > 0) {
+ processes.push({ pid, command });
+ }
+ }
+ return processes;
+}
+
+async function defaultListProcesses(platform: NodeJS.Platform = process.platform): Promise {
+ if (platform === 'win32') {
+ const command = [
+ 'Get-CimInstance Win32_Process',
+ '| Where-Object { $_.CommandLine -ne $null }',
+ '| Select-Object ProcessId,CommandLine',
+ '| ConvertTo-Json -Compress',
+ ].join(' ');
+ const { stdout } = await execFileAsync('powershell.exe', ['-NoProfile', '-Command', command], {
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ return parseWindowsProcessList(stdout);
+ }
+ const { stdout } = await execFileAsync('ps', ['-axo', 'pid=,command='], {
+ encoding: 'utf8',
+ maxBuffer: 10 * 1024 * 1024,
+ });
+ return parsePosixProcessList(stdout);
+}
+
+async function waitUntilStopped(input: {
+ pid: number;
+ processAlive: (pid: number) => boolean;
+ timeoutMs: number;
+ pollIntervalMs: number;
+}): Promise {
+ const deadline = Date.now() + input.timeoutMs;
+ do {
+ if (!input.processAlive(input.pid)) {
+ return true;
+ }
+ if (Date.now() >= deadline) {
+ break;
+ }
+ await delay(input.pollIntervalMs);
+ } while (Date.now() <= deadline);
+ return !input.processAlive(input.pid);
+}
+
+async function discoverStopAllCandidates(
+ options: ManagedPythonDaemonStopAllOptions,
+): Promise<{
+ runtimeRoot: string;
+ candidates: ManagedPythonDaemonStopCandidate[];
+ scanErrors: string[];
+}> {
+ const runtimeRoot = runtimeRootForStopAll(options);
+ const stateCandidates = await readStateCandidates(runtimeRoot);
+ const scanErrors: string[] = [];
+ let processCandidates: ManagedPythonDaemonStopCandidate[] = [];
+ try {
+ const processes = await (options.listProcesses ?? defaultListProcesses)();
+ processCandidates = processes.flatMap((processInfo) => {
+ const candidate = processCandidate(processInfo);
+ return candidate ? [candidate] : [];
+ });
+ } catch (error) {
+ scanErrors.push(error instanceof Error ? error.message : String(error));
+ }
+ return {
+ runtimeRoot,
+ candidates: mergeCandidates([...stateCandidates, ...processCandidates]),
+ scanErrors,
+ };
+}
+
export async function startManagedPythonDaemon(
options: ManagedPythonDaemonStartOptions,
): Promise {
@@ -404,3 +765,63 @@ export async function stopManagedPythonDaemon(
});
return { status: 'stopped', layout, state };
}
+
+export async function stopAllManagedPythonDaemons(
+ options: ManagedPythonDaemonStopAllOptions,
+): Promise {
+ const processAlive = options.processAlive ?? defaultProcessAlive;
+ const killProcess = options.killProcess ?? defaultKillProcess;
+ const stopGraceMs = options.stopGraceMs ?? 500;
+ const pollIntervalMs = options.pollIntervalMs ?? 50;
+ const healthProbeMs = options.healthProbeMs ?? 100;
+ const discovery = await discoverStopAllCandidates(options);
+ const stopped: ManagedPythonDaemonStopAllEntry[] = [];
+ const stale: ManagedPythonDaemonStopAllEntry[] = [];
+ const failed: ManagedPythonDaemonStopAllFailure[] = [];
+
+ for (const candidate of discovery.candidates) {
+ const health = await probeCandidateHealth(candidate, healthProbeMs);
+ const entry = { ...candidateEntry(candidate), ...(health ? { health } : {}) };
+ if (!processAlive(candidate.pid)) {
+ await removeStatePaths(candidate.statePaths);
+ stale.push(entry);
+ continue;
+ }
+ try {
+ killProcess(candidate.pid, 'SIGTERM');
+ if (
+ !(await waitUntilStopped({
+ pid: candidate.pid,
+ processAlive,
+ timeoutMs: stopGraceMs,
+ pollIntervalMs,
+ }))
+ ) {
+ killProcess(candidate.pid, 'SIGKILL');
+ if (
+ !(await waitUntilStopped({
+ pid: candidate.pid,
+ processAlive,
+ timeoutMs: stopGraceMs,
+ pollIntervalMs,
+ }))
+ ) {
+ failed.push({ ...entry, detail: 'Process still running after SIGKILL' });
+ continue;
+ }
+ }
+ await removeStatePaths(candidate.statePaths);
+ stopped.push(entry);
+ } catch (error) {
+ failed.push({ ...entry, detail: error instanceof Error ? error.message : String(error) });
+ }
+ }
+
+ return {
+ runtimeRoot: discovery.runtimeRoot,
+ stopped,
+ stale,
+ failed,
+ scanErrors: discovery.scanErrors,
+ };
+}
diff --git a/packages/cli/src/runtime.test.ts b/packages/cli/src/runtime.test.ts
index e367d339..46f708b2 100644
--- a/packages/cli/src/runtime.test.ts
+++ b/packages/cli/src/runtime.test.ts
@@ -1,5 +1,6 @@
import { describe, expect, it, vi } from 'vitest';
import type {
+ ManagedPythonDaemonStopAllResult,
ManagedPythonDaemonStartResult,
ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
@@ -199,13 +200,63 @@ describe('runKtxRuntime', () => {
})),
};
- await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0' }, io.io, deps)).resolves.toBe(0);
+ await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: false }, io.io, deps)).resolves.toBe(0);
expect(deps.stopDaemon).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
expect(io.stdout()).toContain('Stopped KTX Python daemon');
expect(io.stdout()).toContain('pid: 4242');
});
+ it('stops all discovered Python daemons and reports the summary', async () => {
+ const io = makeIo();
+ const deps: KtxRuntimeDeps = {
+ stopAllDaemons: vi.fn(async (): Promise => ({
+ runtimeRoot: '/runtime',
+ stopped: [
+ { pid: 4242, source: 'state', url: 'http://127.0.0.1:61234', statePaths: ['/runtime/0.2.0/daemon.json'] },
+ { pid: 5252, source: 'process', url: 'http://127.0.0.1:8765', statePaths: [] },
+ ],
+ stale: [],
+ failed: [],
+ scanErrors: [],
+ })),
+ };
+
+ await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(0);
+
+ expect(deps.stopAllDaemons).toHaveBeenCalledWith({ cliVersion: '0.2.0' });
+ expect(io.stdout()).toContain('Stopped 2 KTX Python daemons');
+ expect(io.stdout()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
+ expect(io.stdout()).toContain('pid: 5252 source: process url: http://127.0.0.1:8765');
+ });
+
+ it('returns failure when stop all cannot stop every daemon', async () => {
+ const io = makeIo();
+ const deps: KtxRuntimeDeps = {
+ stopAllDaemons: vi.fn(async (): Promise => ({
+ runtimeRoot: '/runtime',
+ stopped: [],
+ stale: [],
+ failed: [
+ {
+ pid: 4242,
+ source: 'state',
+ url: 'http://127.0.0.1:61234',
+ statePaths: ['/runtime/0.2.0/daemon.json'],
+ detail: 'Process still running after SIGKILL',
+ },
+ ],
+ scanErrors: ['ps failed'],
+ })),
+ };
+
+ await expect(runKtxRuntime({ command: 'stop', cliVersion: '0.2.0', all: true }, io.io, deps)).resolves.toBe(1);
+
+ expect(io.stderr()).toContain('Stopped 0 KTX Python daemons; failed 1');
+ expect(io.stderr()).toContain('pid: 4242 source: state url: http://127.0.0.1:61234');
+ expect(io.stderr()).toContain('process scan: ps failed');
+ });
+
it('prints runtime status as JSON', async () => {
const io = makeIo();
const deps: KtxRuntimeDeps = {
diff --git a/packages/cli/src/runtime.ts b/packages/cli/src/runtime.ts
index fe2b5f74..e88f2b31 100644
--- a/packages/cli/src/runtime.ts
+++ b/packages/cli/src/runtime.ts
@@ -1,7 +1,9 @@
import type { KtxCliIo } from './cli-runtime.js';
import {
+ stopAllManagedPythonDaemons,
startManagedPythonDaemon,
stopManagedPythonDaemon,
+ type ManagedPythonDaemonStopAllResult,
type ManagedPythonDaemonStartResult,
type ManagedPythonDaemonStopResult,
} from './managed-python-daemon.js';
@@ -22,7 +24,7 @@ import {
export type KtxRuntimeArgs =
| { command: 'install'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
| { command: 'start'; cliVersion: string; feature: KtxRuntimeFeature; force: boolean }
- | { command: 'stop'; cliVersion: string }
+ | { command: 'stop'; cliVersion: string; all: boolean }
| { command: 'status'; cliVersion: string; json: boolean }
| { command: 'doctor'; cliVersion: string; json: boolean }
| { command: 'prune'; cliVersion: string; dryRun: boolean; yes: boolean };
@@ -35,6 +37,7 @@ export interface KtxRuntimeDeps {
force?: boolean;
}) => Promise;
stopDaemon?: (options: { cliVersion: string }) => Promise;
+ stopAllDaemons?: (options: { cliVersion: string }) => Promise;
readStatus?: (options: ManagedPythonRuntimeLayoutOptions) => Promise;
doctorRuntime?: (options: ManagedPythonRuntimeLayoutOptions) => Promise;
pruneRuntime?: (options: {
@@ -81,6 +84,58 @@ function writeDaemonStop(io: KtxCliIo, result: ManagedPythonDaemonStopResult): v
io.stdout.write(`state: ${result.layout.daemonStatePath}\n`);
}
+function writeStopAllEntry(io: KtxCliIo, entry: { pid: number; source: string; url?: string; health?: string; detail?: string }): void {
+ io.stdout.write(
+ `pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${
+ entry.health ? ` health: ${entry.health}` : ''
+ }${
+ entry.detail ? ` detail: ${entry.detail}` : ''
+ }\n`,
+ );
+}
+
+function writeDaemonStopAll(io: KtxCliIo, result: ManagedPythonDaemonStopAllResult): number {
+ const failed = result.failed.length + result.scanErrors.length;
+ if (
+ result.stopped.length === 0 &&
+ result.stale.length === 0 &&
+ result.failed.length === 0 &&
+ result.scanErrors.length === 0
+ ) {
+ io.stdout.write('No KTX Python daemons found\n');
+ return 0;
+ }
+ if (failed === 0) {
+ io.stdout.write(`Stopped ${result.stopped.length} KTX Python daemons\n`);
+ if (result.stale.length > 0) {
+ io.stdout.write(`Cleaned ${result.stale.length} stale daemon states\n`);
+ }
+ for (const entry of result.stopped) {
+ writeStopAllEntry(io, entry);
+ }
+ for (const entry of result.stale) {
+ writeStopAllEntry(io, entry);
+ }
+ return 0;
+ }
+ io.stderr.write(
+ `Stopped ${result.stopped.length} KTX Python daemons; failed ${result.failed.length}${
+ result.stale.length > 0 ? `; cleaned stale ${result.stale.length}` : ''
+ }\n`,
+ );
+ for (const entry of result.failed) {
+ io.stderr.write(
+ `pid: ${entry.pid} source: ${entry.source}${entry.url ? ` url: ${entry.url}` : ''}${
+ entry.health ? ` health: ${entry.health}` : ''
+ } detail: ${entry.detail}\n`,
+ );
+ }
+ for (const error of result.scanErrors) {
+ io.stderr.write(`process scan: ${error}\n`);
+ }
+ return 1;
+}
+
function writeStatus(io: KtxCliIo, status: ManagedPythonRuntimeStatus): void {
io.stdout.write('KTX Python runtime\n');
io.stdout.write(`status: ${status.kind}\n`);
@@ -142,10 +197,16 @@ export async function runKtxRuntime(
return 0;
}
if (args.command === 'stop') {
- const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
- const result = await stopDaemon({ cliVersion: args.cliVersion });
- writeDaemonStop(io, result);
- return 0;
+ if (args.all) {
+ const stopAllDaemons = deps.stopAllDaemons ?? stopAllManagedPythonDaemons;
+ const result = await stopAllDaemons({ cliVersion: args.cliVersion });
+ return writeDaemonStopAll(io, result);
+ } else {
+ const stopDaemon = deps.stopDaemon ?? stopManagedPythonDaemon;
+ const result = await stopDaemon({ cliVersion: args.cliVersion });
+ writeDaemonStop(io, result);
+ return 0;
+ }
}
if (args.command === 'status') {
const readStatus = deps.readStatus ?? readManagedPythonRuntimeStatus;
diff --git a/packages/cli/src/setup.test.ts b/packages/cli/src/setup.test.ts
index cd6e9a9f..58efc506 100644
--- a/packages/cli/src/setup.test.ts
+++ b/packages/cli/src/setup.test.ts
@@ -3,6 +3,7 @@ import { tmpdir } from 'node:os';
import { join } from 'node:path';
import { afterEach, beforeEach, describe, expect, it, vi } from 'vitest';
+import { localFakeBundleReport, persistLocalBundleReport } from './ingest.test-utils.js';
import { contextBuildCommands, writeKtxSetupContextState } from './setup-context.js';
import { runDemoTour } from './setup-demo-tour.js';
import { readKtxSetupStatus, runKtxSetup } from './setup.js';
@@ -311,6 +312,62 @@ describe('setup status', () => {
});
});
+ it('reports Vertex LLM and context ready after a successful Metabase ingest report', async () => {
+ await writeFile(
+ join(tempDir, 'ktx.yaml'),
+ [
+ 'project: revenue',
+ 'setup:',
+ ' database_connection_ids:',
+ ' - warehouse',
+ ' completed_steps:',
+ ' - project',
+ ' - databases',
+ ' - sources',
+ 'connections:',
+ ' warehouse:',
+ ' driver: postgres',
+ ' url: env:DATABASE_URL',
+ ' metabase:',
+ ' driver: metabase',
+ ' url: env:METABASE_URL',
+ ' api_key_ref: env:METABASE_API_KEY',
+ ' warehouse_connection_id: warehouse',
+ 'llm:',
+ ' provider:',
+ ' backend: vertex',
+ ' vertex:',
+ ' project: kaelio-dev',
+ ' location: us-east5',
+ ' models:',
+ ' default: claude-sonnet-4-6',
+ 'ingest:',
+ ' embeddings:',
+ ' backend: deterministic',
+ ' model: deterministic',
+ ' dimensions: 8',
+ '',
+ ].join('\n'),
+ 'utf-8',
+ );
+ await persistLocalBundleReport(
+ tempDir,
+ localFakeBundleReport('metabase-job-1', {
+ connectionId: 'warehouse',
+ sourceKey: 'metabase',
+ }),
+ );
+
+ const status = await readKtxSetupStatus(tempDir);
+ const io = makeIo();
+ await expect(runKtxSetup({ command: 'status', projectDir: tempDir, json: false }, io.io)).resolves.toBe(0);
+
+ expect(status.llm).toMatchObject({ backend: 'vertex', ready: true, model: 'claude-sonnet-4-6' });
+ expect(status.context).toMatchObject({ ready: true, status: 'completed' });
+ expect(io.stdout()).toContain('LLM ready: yes (claude-sonnet-4-6)');
+ expect(io.stdout()).toContain('KTX context built: yes');
+ });
+
it('prints plain and JSON setup status', async () => {
const plainIo = makeIo();
const jsonIo = makeIo();
diff --git a/packages/cli/src/setup.ts b/packages/cli/src/setup.ts
index 47a7997a..a4f081d0 100644
--- a/packages/cli/src/setup.ts
+++ b/packages/cli/src/setup.ts
@@ -1,7 +1,8 @@
import { existsSync } from 'node:fs';
import { join, resolve } from 'node:path';
import { cancel, isCancel, select } from '@clack/prompts';
-import { loadKtxProject } from '@ktx/context/project';
+import { getLatestLocalIngestStatus, savedMemoryCountsForReport } from '@ktx/context/ingest';
+import { ktxLocalStateDbPath, loadKtxProject, type KtxLocalProject } from '@ktx/context/project';
import type { KtxCliIo } from './cli-runtime.js';
import { formatSetupNextStepLines } from './next-steps.js';
import { isKtxSetupExitError, withSetupInterruptConfirmation } from './setup-interrupt.js';
@@ -248,6 +249,31 @@ function sourceConnections(config: Awaited>['c
.sort((left, right) => left.connectionId.localeCompare(right.connectionId));
}
+type LocalIngestStatusReport = NonNullable>>;
+
+function reportHasSavedContext(report: LocalIngestStatusReport): boolean {
+ if (report.body.failedWorkUnits.length > 0) {
+ return false;
+ }
+ const counts = savedMemoryCountsForReport(report);
+ return counts.wikiCount > 0 || counts.slCount > 0;
+}
+
+async function readIngestContextStatus(project: KtxLocalProject): Promise {
+ if (!existsSync(ktxLocalStateDbPath(project))) {
+ return null;
+ }
+ const report = await getLatestLocalIngestStatus(project);
+ if (!report || !reportHasSavedContext(report)) {
+ return null;
+ }
+ return {
+ ready: true,
+ status: 'completed',
+ runId: report.runId,
+ };
+}
+
export async function readKtxSetupStatus(projectDir: string): Promise {
const resolvedProjectDir = resolve(projectDir);
if (!existsSync(join(resolvedProjectDir, 'ktx.yaml'))) {
@@ -279,6 +305,10 @@ export async function readKtxSetupStatus(projectDir: string): Promise {
const knowledgeSearch = structuredContent<{
results: Array<{ key: string; summary: string; score: number }>;
totalFound: number;
- }>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract', limit: 5 } }));
+ }>(await client.callTool({ name: 'knowledge_search', arguments: { query: 'ARR contract-first definition', limit: 10 } }));
expect(knowledgeSearch.totalFound).toBeGreaterThan(0);
- expect(knowledgeSearch.results.map((result) => result.key)).toContain('arr-contract-first');
+ expect(knowledgeSearch.results.map((result) => result.key)).toContain('orbit-arr-contract-first-definition');
const knowledgeRead = structuredContent<{
key: string;
@@ -378,26 +378,26 @@ describe('standalone built ktx CLI smoke', () => {
content: string;
tags: string[];
slRefs: string[];
- }>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'arr-contract-first' } }));
- expect(knowledgeRead.key).toBe('arr-contract-first');
+ }>(await client.callTool({ name: 'knowledge_read', arguments: { key: 'orbit-arr-contract-first-definition' } }));
+ expect(knowledgeRead.key).toBe('orbit-arr-contract-first-definition');
expect(knowledgeRead.summary).toContain('ARR');
expect(knowledgeRead.content).toContain('contract');
- expect(knowledgeRead.slRefs).toContain('orbit_demo.contracts');
+ expect(knowledgeRead.slRefs).toContain('mart_arr_daily');
const slRead = structuredContent<{ sourceName: string; yaml: string }>(
await client.callTool({
name: 'sl_read_source',
- arguments: { connectionId: 'orbit_demo', sourceName: 'accounts' },
+ arguments: { connectionId: 'dbt-main', sourceName: 'mart_arr_daily' },
}),
);
- expect(slRead.sourceName).toBe('accounts');
- expect(slRead.yaml).toContain('name: accounts');
+ expect(slRead.sourceName).toBe('mart_arr_daily');
+ expect(slRead.yaml).toContain('name: mart_arr_daily');
expect(slRead.yaml).toContain('measures:');
const slValidate = structuredContent<{ success: boolean; errors: string[]; warnings: string[] }>(
await client.callTool({
name: 'sl_validate',
- arguments: { connectionId: 'orbit_demo', names: ['accounts', 'contracts'] },
+ arguments: { connectionId: 'dbt-main', names: ['mart_arr_daily', 'stg_contracts'] },
}),
);
expect(slValidate.success).toBe(true);
@@ -716,7 +716,7 @@ describe('standalone built ktx CLI smoke', () => {
'--project-dir',
projectDir,
'--token-env',
- 'NOTION_AUTH_TOKEN',
+ 'NOTION_TOKEN',
'--crawl-mode',
'all_accessible',
'--max-pages',
@@ -729,7 +729,7 @@ describe('standalone built ktx CLI smoke', () => {
const yaml = await readFile(join(projectDir, 'ktx.yaml'), 'utf-8');
expect(yaml).toContain('driver: notion');
- expect(yaml).toContain('auth_token_ref: env:NOTION_AUTH_TOKEN');
+ expect(yaml).toContain('auth_token_ref: env:NOTION_TOKEN');
expect(yaml).toContain('crawl_mode: all_accessible');
expect(yaml).toContain('max_pages_per_run: 5');
expect(yaml).not.toContain('ntn_');
@@ -737,7 +737,7 @@ describe('standalone built ktx CLI smoke', () => {
const parsed = parseKtxProjectConfig(yaml);
expect(parsed.connections['notion-main']).toMatchObject({
driver: 'notion',
- auth_token_ref: 'env:NOTION_AUTH_TOKEN',
+ auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'all_accessible',
});
});
diff --git a/packages/context/src/connections/notion-config.test.ts b/packages/context/src/connections/notion-config.test.ts
index 33d1e110..8ad88c86 100644
--- a/packages/context/src/connections/notion-config.test.ts
+++ b/packages/context/src/connections/notion-config.test.ts
@@ -23,14 +23,14 @@ describe('standalone Notion connection config', () => {
it('parses selected-root Notion config with safe defaults', () => {
const parsed = parseNotionConnectionConfig({
driver: 'notion',
- auth_token_ref: 'env:NOTION_AUTH_TOKEN',
+ auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'selected_roots',
root_page_ids: ['page-1'],
});
expect(parsed).toEqual({
driver: 'notion',
- auth_token_ref: 'env:NOTION_AUTH_TOKEN',
+ auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'selected_roots',
root_page_ids: ['page-1'],
root_database_ids: [],
@@ -70,7 +70,7 @@ describe('standalone Notion connection config', () => {
expect(() =>
parseNotionConnectionConfig({
driver: 'notion',
- auth_token_ref: 'env:NOTION_AUTH_TOKEN',
+ auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'selected_roots',
}),
).toThrow('selected_roots requires at least one root page, database, or data source id');
@@ -81,8 +81,8 @@ describe('standalone Notion connection config', () => {
await writeFile(tokenPath, 'ntn_file_token\n', 'utf-8');
await expect(
- resolveNotionAuthToken('env:NOTION_AUTH_TOKEN', {
- env: { NOTION_AUTH_TOKEN: 'ntn_env_token' },
+ resolveNotionAuthToken('env:NOTION_TOKEN', {
+ env: { NOTION_TOKEN: 'ntn_env_token' },
}),
).resolves.toBe('ntn_env_token');
await expect(resolveNotionAuthToken(`file:${tokenPath}`)).resolves.toBe('ntn_file_token');
@@ -95,14 +95,14 @@ describe('standalone Notion connection config', () => {
const pullConfig = await notionConnectionToPullConfig(
parseNotionConnectionConfig({
driver: 'notion',
- auth_token_ref: 'env:NOTION_AUTH_TOKEN',
+ auth_token_ref: 'env:NOTION_TOKEN',
crawl_mode: 'all_accessible',
max_pages_per_run: 12,
max_knowledge_creates_per_run: 2,
max_knowledge_updates_per_run: 7,
last_successful_cursor: '{"phase":"all_accessible_pages","cursor":"cursor-1"}',
}),
- { env: { NOTION_AUTH_TOKEN: 'ntn_env_token' } },
+ { env: { NOTION_TOKEN: 'ntn_env_token' } },
);
expect(pullConfig).toEqual({
diff --git a/packages/context/src/ingest/local-stage-ingest.test.ts b/packages/context/src/ingest/local-stage-ingest.test.ts
index e24174fb..157bd96b 100644
--- a/packages/context/src/ingest/local-stage-ingest.test.ts
+++ b/packages/context/src/ingest/local-stage-ingest.test.ts
@@ -569,8 +569,8 @@ describe('local ingest', () => {
});
it('passes resolved standalone Notion config into fetch adapters', async () => {
- const priorToken = process.env.NOTION_AUTH_TOKEN;
- process.env.NOTION_AUTH_TOKEN = 'ntn_local_test_token';
+ const priorToken = process.env.NOTION_TOKEN;
+ process.env.NOTION_TOKEN = 'ntn_local_test_token';
try {
await writeFile(
join(project.projectDir, 'ktx.yaml'),
@@ -579,7 +579,7 @@ describe('local ingest', () => {
'connections:',
' notion-main:',
' driver: notion',
- ' auth_token_ref: env:NOTION_AUTH_TOKEN',
+ ' auth_token_ref: env:NOTION_TOKEN',
' crawl_mode: selected_roots',
' root_page_ids:',
' - page-1',
@@ -666,9 +666,9 @@ describe('local ingest', () => {
});
} finally {
if (priorToken === undefined) {
- delete process.env.NOTION_AUTH_TOKEN;
+ delete process.env.NOTION_TOKEN;
} else {
- process.env.NOTION_AUTH_TOKEN = priorToken;
+ process.env.NOTION_TOKEN = priorToken;
}
}
});
diff --git a/packages/context/src/memory/memory-agent.service.ingest.test.ts b/packages/context/src/memory/memory-agent.service.ingest.test.ts
index 710ba956..6375e494 100644
--- a/packages/context/src/memory/memory-agent.service.ingest.test.ts
+++ b/packages/context/src/memory/memory-agent.service.ingest.test.ts
@@ -37,6 +37,7 @@ interface BuiltMocks {
agentRunner: any;
slValidator: any;
toolsetFactory: any;
+ logger: any;
}
const buildMocks = (overrides: Partial = {}): BuiltMocks => {
@@ -131,6 +132,7 @@ const buildMocks = (overrides: Partial = {}): BuiltMocks => {
getAllTools: vi.fn().mockReturnValue([]),
}),
},
+ logger: { log: vi.fn(), warn: vi.fn(), error: vi.fn(), debug: vi.fn() },
};
return { ...defaults, ...overrides };
@@ -179,6 +181,7 @@ const buildService = (mocks: BuiltMocks): MemoryAgentService =>
telemetry: {
trackMemoryIngestion: mocks.eventTracker.trackEvent,
},
+ logger: mocks.logger,
});
const baseInput = {
@@ -238,6 +241,27 @@ describe('MemoryAgentService.ingest — session-branch orchestration', () => {
expect(result.commitHash).toBe('cafebabe');
});
+ it('logs prompt debug output when KTX_MEMORY_AGENT_DEBUG_PROMPTS is enabled', async () => {
+ const previousDebugPrompts = process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
+ const mocks = buildMocks();
+ const svc = buildService(mocks);
+
+ try {
+ process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = '1';
+
+ await svc.ingest(baseInput);
+
+ expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] system='));
+ expect(mocks.logger.debug).toHaveBeenCalledWith(expect.stringContaining('[memory-agent prompt-debug] user='));
+ } finally {
+ if (previousDebugPrompts === undefined) {
+ delete process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS;
+ } else {
+ process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS = previousDebugPrompts;
+ }
+ }
+ });
+
it('empty path: squash returns no touched paths → no enqueue, cleanup(empty), commitHash=null', async () => {
const mocks = buildMocks();
mocks.gitService.squashMergeIntoMain.mockResolvedValue({
diff --git a/packages/context/src/memory/memory-agent.service.ts b/packages/context/src/memory/memory-agent.service.ts
index fd1f0a6c..6f239053 100644
--- a/packages/context/src/memory/memory-agent.service.ts
+++ b/packages/context/src/memory/memory-agent.service.ts
@@ -192,7 +192,7 @@ export class MemoryAgentService {
`[memory-agent] chat=${chatId} running (sourceType=${sourceType}, hasSL=${hasSL}, budget=${stepBudget}, model=${modelName})${signalsSuffix}${dialectSuffix}`,
);
- if (process.env.MEMORY_AGENT_DEBUG_PROMPTS === '1') {
+ if (process.env.KTX_MEMORY_AGENT_DEBUG_PROMPTS === '1') {
this.logger.debug(`[memory-agent prompt-debug] system=${systemPrompt}`);
this.logger.debug(`[memory-agent prompt-debug] user=${prompt}`);
}
diff --git a/packages/llm/package.json b/packages/llm/package.json
index fc7deeba..13f49666 100644
--- a/packages/llm/package.json
+++ b/packages/llm/package.json
@@ -27,6 +27,7 @@
},
"dependencies": {
"@ai-sdk/anthropic": "3.0.71",
+ "@ai-sdk/devtools": "0.0.17",
"@ai-sdk/google-vertex": "^4.0.112",
"ai": "^6.0.168",
"openai": "^6.25.0"
diff --git a/packages/llm/src/model-health.test.ts b/packages/llm/src/model-health.test.ts
index 003c12d5..d1b3df47 100644
--- a/packages/llm/src/model-health.test.ts
+++ b/packages/llm/src/model-health.test.ts
@@ -1,3 +1,4 @@
+import { wrapLanguageModel as defaultWrapLanguageModel } from 'ai';
import { describe, expect, it, vi } from 'vitest';
import { runKtxLlmHealthCheck } from './model-health.js';
@@ -7,6 +8,7 @@ describe('KTX LLM health check', () => {
it('runs a minimal non-streaming model call through the configured provider', async () => {
const generateText = vi.fn(async () => ({ text: 'ok' }));
const createAnthropic = vi.fn(() => vi.fn(() => anthropicModel));
+ const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
await expect(
runKtxLlmHealthCheck(
@@ -15,7 +17,7 @@ describe('KTX LLM health check', () => {
anthropic: { apiKey: 'sk-ant-test' },
modelSlots: { default: 'claude-sonnet-4-6' },
},
- { deps: { createAnthropic, generateText } },
+ { deps: { createAnthropic, generateText, devtoolsEnabled: true, wrapLanguageModel } },
),
).resolves.toEqual({ ok: true });
@@ -32,6 +34,7 @@ describe('KTX LLM health check', () => {
maxOutputTokens: 8,
}),
);
+ expect(wrapLanguageModel).not.toHaveBeenCalled();
});
it('returns a failed result without exposing secret values', async () => {
diff --git a/packages/llm/src/model-health.ts b/packages/llm/src/model-health.ts
index 131822b6..abbc2735 100644
--- a/packages/llm/src/model-health.ts
+++ b/packages/llm/src/model-health.ts
@@ -41,7 +41,7 @@ export async function runKtxLlmHealthCheck(
): Promise {
try {
const { generateText: runGenerateTextOverride, ...providerDeps } = options.deps ?? {};
- const provider = createKtxLlmProvider(config, providerDeps);
+ const provider = createKtxLlmProvider(config, { ...providerDeps, devtoolsEnabled: false });
const runGenerateText = runGenerateTextOverride ?? generateText;
await withTimeout(
runGenerateText({
diff --git a/packages/llm/src/model-provider.test.ts b/packages/llm/src/model-provider.test.ts
index 55dd5da9..ff65a12a 100644
--- a/packages/llm/src/model-provider.test.ts
+++ b/packages/llm/src/model-provider.test.ts
@@ -1,10 +1,138 @@
-import type { LanguageModel } from 'ai';
+import { devToolsMiddleware as defaultDevToolsMiddleware } from '@ai-sdk/devtools';
+import { wrapLanguageModel as defaultWrapLanguageModel, type LanguageModel } from 'ai';
import { describe, expect, it, vi } from 'vitest';
-import { createKtxLlmProvider } from './model-provider.js';
+import { createKtxLlmProvider, type KtxLlmProviderFactoryDeps } from './model-provider.js';
const languageModel = (modelId: string, provider = 'test'): LanguageModel => ({ modelId, provider }) as LanguageModel;
+const devtoolsMiddleware = (): ReturnType => ({ specificationVersion: 'v3' });
+const wrapWith = (model: LanguageModel) =>
+ vi.fn((_options: Parameters[0]) => model as ReturnType);
describe('createKtxLlmProvider', () => {
+ it('wraps language models with DevTools middleware when explicitly enabled', () => {
+ const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
+ const wrappedModel = languageModel('claude-sonnet-4-6', 'anthropic-devtools');
+ const middleware = devtoolsMiddleware();
+ const wrapLanguageModel = wrapWith(wrappedModel);
+ const devToolsMiddleware = vi.fn(devtoolsMiddleware);
+
+ const provider = createKtxLlmProvider(
+ {
+ backend: 'anthropic',
+ anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
+ modelSlots: { default: 'claude-sonnet-4-6' },
+ promptCaching: { enabled: false },
+ },
+ {
+ createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
+ devtoolsEnabled: true,
+ wrapLanguageModel,
+ devToolsMiddleware,
+ } satisfies KtxLlmProviderFactoryDeps,
+ );
+
+ expect(provider.getModel('default')).toBe(wrappedModel);
+ expect(devToolsMiddleware).toHaveBeenCalledTimes(1);
+ expect(wrapLanguageModel).toHaveBeenCalledWith({
+ model: anthropicModel,
+ middleware,
+ modelId: 'claude-sonnet-4-6',
+ providerId: 'anthropic',
+ });
+ });
+
+ it('does not wrap language models by default', () => {
+ const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
+ const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
+ const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware);
+
+ const provider = createKtxLlmProvider(
+ {
+ backend: 'anthropic',
+ anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
+ modelSlots: { default: 'claude-sonnet-4-6' },
+ promptCaching: { enabled: false },
+ },
+ {
+ createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
+ wrapLanguageModel,
+ devToolsMiddleware,
+ } satisfies KtxLlmProviderFactoryDeps,
+ );
+
+ expect(provider.getModel('default')).toBe(anthropicModel);
+ expect(wrapLanguageModel).not.toHaveBeenCalled();
+ expect(devToolsMiddleware).not.toHaveBeenCalled();
+ });
+
+ it('wraps language models when KTX_AI_DEVTOOLS_ENABLED is true', () => {
+ const originalEnv = process.env.KTX_AI_DEVTOOLS_ENABLED;
+ process.env.KTX_AI_DEVTOOLS_ENABLED = 'true';
+ try {
+ const gatewayModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway');
+ const wrappedModel = languageModel('anthropic/claude-sonnet-4-6', 'gateway-devtools');
+ const wrapLanguageModel = wrapWith(wrappedModel);
+
+ const provider = createKtxLlmProvider(
+ {
+ backend: 'gateway',
+ gateway: { baseURL: 'https://gateway.test/v1' },
+ modelSlots: { default: 'anthropic/claude-sonnet-4-6' },
+ promptCaching: { enabled: false },
+ },
+ {
+ createGateway: vi.fn(() => vi.fn(() => gatewayModel)),
+ wrapLanguageModel,
+ devToolsMiddleware: vi.fn(devtoolsMiddleware),
+ } satisfies KtxLlmProviderFactoryDeps,
+ );
+
+ expect(provider.getModel('default')).toBe(wrappedModel);
+ expect(wrapLanguageModel).toHaveBeenCalledTimes(1);
+ } finally {
+ if (originalEnv === undefined) {
+ delete process.env.KTX_AI_DEVTOOLS_ENABLED;
+ } else {
+ process.env.KTX_AI_DEVTOOLS_ENABLED = originalEnv;
+ }
+ }
+ });
+
+ it('does not wrap language models in production even when enabled', () => {
+ const originalNodeEnv = process.env.NODE_ENV;
+ process.env.NODE_ENV = 'production';
+ try {
+ const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
+ const wrapLanguageModel = vi.fn(defaultWrapLanguageModel);
+ const devToolsMiddleware = vi.fn(defaultDevToolsMiddleware);
+
+ const provider = createKtxLlmProvider(
+ {
+ backend: 'anthropic',
+ anthropic: { apiKey: 'test-anthropic-key' }, // pragma: allowlist secret
+ modelSlots: { default: 'claude-sonnet-4-6' },
+ promptCaching: { enabled: false },
+ },
+ {
+ createAnthropic: vi.fn(() => vi.fn(() => anthropicModel)),
+ devtoolsEnabled: true,
+ wrapLanguageModel,
+ devToolsMiddleware,
+ } satisfies KtxLlmProviderFactoryDeps,
+ );
+
+ expect(provider.getModel('default')).toBe(anthropicModel);
+ expect(wrapLanguageModel).not.toHaveBeenCalled();
+ expect(devToolsMiddleware).not.toHaveBeenCalled();
+ } finally {
+ if (originalNodeEnv === undefined) {
+ delete process.env.NODE_ENV;
+ } else {
+ process.env.NODE_ENV = originalNodeEnv;
+ }
+ }
+ });
+
it('uses direct Anthropic with both beta headers', () => {
const anthropicModel = languageModel('claude-sonnet-4-6', 'anthropic');
const anthropic = vi.fn(() => anthropicModel);
diff --git a/packages/llm/src/model-provider.ts b/packages/llm/src/model-provider.ts
index 66a9fddd..6dbdcb06 100644
--- a/packages/llm/src/model-provider.ts
+++ b/packages/llm/src/model-provider.ts
@@ -1,6 +1,7 @@
import { createAnthropic } from '@ai-sdk/anthropic';
+import { devToolsMiddleware } from '@ai-sdk/devtools';
import { createVertexAnthropic } from '@ai-sdk/google-vertex/anthropic';
-import { createGateway, generateText, type LanguageModel } from 'ai';
+import { createGateway, generateText, wrapLanguageModel, type LanguageModel } from 'ai';
import { createKtxToolCallRepairHandler } from './repair.js';
import type {
KtxLlmConfig,
@@ -21,6 +22,9 @@ export interface KtxLlmProviderFactoryDeps {
createVertexAnthropic?: VertexAnthropicFactory;
createGateway?: GatewayFactory;
generateText?: typeof generateText;
+ devtoolsEnabled?: boolean;
+ wrapLanguageModel?: typeof wrapLanguageModel;
+ devToolsMiddleware?: typeof devToolsMiddleware;
}
const DEFAULT_PROMPT_CACHING: KtxPromptCachingConfig = {
@@ -40,10 +44,27 @@ function resolvePromptCaching(config: KtxLlmConfig): KtxPromptCachingConfig {
return { ...DEFAULT_PROMPT_CACHING, ...config.promptCaching };
}
+function resolveDevtoolsEnabled(override: boolean | undefined): boolean {
+ if (process.env.NODE_ENV === 'production') {
+ return false;
+ }
+ if (override !== undefined) {
+ return override;
+ }
+ const value = process.env.KTX_AI_DEVTOOLS_ENABLED?.trim().toLowerCase();
+ return value === 'true' || value === '1' || value === 'yes';
+}
+
export function modelIdFromLanguageModel(model: LanguageModel | string): string {
return typeof model === 'string' ? model : ((model as { modelId?: string }).modelId ?? '');
}
+function providerIdFromLanguageModel(model: Exclude): string | undefined {
+ return typeof (model as { provider?: unknown }).provider === 'string'
+ ? (model as { provider: string }).provider
+ : undefined;
+}
+
export function isAnthropicProtocolModel(model: LanguageModel | string): boolean {
const modelId = modelIdFromLanguageModel(model);
return modelId.startsWith('claude-') || modelId.startsWith('anthropic/') || modelId.includes('/claude-');
@@ -53,6 +74,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
private readonly promptCaching: KtxPromptCachingConfig;
private readonly getModelByResolvedName: (modelId: string) => LanguageModel;
private readonly runGenerateText: typeof generateText;
+ private readonly devtoolsEnabled: boolean;
+ private readonly runWrapLanguageModel: typeof wrapLanguageModel;
+ private readonly createDevToolsMiddleware: typeof devToolsMiddleware;
constructor(
private readonly config: KtxLlmConfig,
@@ -60,6 +84,9 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
) {
this.promptCaching = resolvePromptCaching(config);
this.runGenerateText = deps.generateText ?? generateText;
+ this.devtoolsEnabled = resolveDevtoolsEnabled(deps.devtoolsEnabled);
+ this.runWrapLanguageModel = deps.wrapLanguageModel ?? wrapLanguageModel;
+ this.createDevToolsMiddleware = deps.devToolsMiddleware ?? devToolsMiddleware;
this.getModelByResolvedName = this.createModelFactory(config, deps);
}
@@ -68,7 +95,7 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
}
getModelByName(modelId: string): LanguageModel {
- return this.getModelByResolvedName(modelId);
+ return this.withDevtools(this.getModelByResolvedName(modelId));
}
cacheMarker(ttl: KtxPromptCacheTtl, model?: LanguageModel | string) {
@@ -113,6 +140,18 @@ class DefaultKtxLlmProvider implements KtxLlmProvider {
return this.config.modelSlots[role] ?? this.config.modelSlots.default;
}
+ private withDevtools(model: LanguageModel): LanguageModel {
+ if (!this.devtoolsEnabled || typeof model === 'string') {
+ return model;
+ }
+ return this.runWrapLanguageModel({
+ model: model as Parameters[0]['model'],
+ middleware: this.createDevToolsMiddleware(),
+ modelId: modelIdFromLanguageModel(model),
+ providerId: providerIdFromLanguageModel(model),
+ });
+ }
+
private createModelFactory(config: KtxLlmConfig, deps: KtxLlmProviderFactoryDeps): (modelId: string) => LanguageModel {
if (config.backend === 'anthropic') {
const anthropic = (deps.createAnthropic ?? createAnthropic)({
diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml
index e74389f9..0cd6dbeb 100644
--- a/pnpm-lock.yaml
+++ b/pnpm-lock.yaml
@@ -355,6 +355,9 @@ importers:
'@ai-sdk/anthropic':
specifier: 3.0.71
version: 3.0.71(zod@4.4.3)
+ '@ai-sdk/devtools':
+ specifier: 0.0.17
+ version: 0.0.17
'@ai-sdk/google-vertex':
specifier: ^4.0.112
version: 4.0.118(zod@4.4.3)
@@ -389,6 +392,11 @@ packages:
peerDependencies:
zod: ^3.25.76 || ^4.1.8
+ '@ai-sdk/devtools@0.0.17':
+ resolution: {integrity: sha512-CJgo+3DMHOJbxxq1qTgnW4vpFXgBW1pHePMimBW4Go5FPU7iLqppoGX/UC798IXqlD3hncQRPfyBLZjbsJC91w==}
+ engines: {node: '>=18'}
+ hasBin: true
+
'@ai-sdk/gateway@3.0.104':
resolution: {integrity: sha512-ZKX5n74io8VIRlhIMSLWVlvT3sXC8Z7cZ9GHuWBWZDVi96+62AIsWuLGvMfcBA1STYuSoDrp6rIziZmvrTq0TA==}
engines: {node: '>=18'}
@@ -4576,6 +4584,12 @@ snapshots:
'@ai-sdk/provider-utils': 4.0.26(zod@4.4.3)
zod: 4.4.3
+ '@ai-sdk/devtools@0.0.17':
+ dependencies:
+ '@ai-sdk/provider': 3.0.10
+ '@hono/node-server': 1.19.14(hono@4.12.15)
+ hono: 4.12.15
+
'@ai-sdk/gateway@3.0.104(zod@4.3.6)':
dependencies:
'@ai-sdk/provider': 3.0.8
diff --git a/scripts/examples-docs.test.mjs b/scripts/examples-docs.test.mjs
index 24c83452..81c42b9c 100644
--- a/scripts/examples-docs.test.mjs
+++ b/scripts/examples-docs.test.mjs
@@ -135,72 +135,86 @@ describe('standalone example docs', () => {
assert.doesNotMatch(readme, /--historic-sql-min-calls/);
});
- it('lists every published TypeScript package in the package root README', async () => {
- const rootReadme = await readText('README.md');
+ it('lists every workspace package in the contributor docs', async () => {
+ const contributing = await readText('docs-site/content/docs/community/contributing.mdx');
- assert.match(rootReadme, /`packages\/context`/);
- assert.match(rootReadme, /`packages\/cli`/);
- assert.match(rootReadme, /`packages\/connector-bigquery`/);
- assert.match(rootReadme, /`packages\/connector-clickhouse`/);
- assert.match(rootReadme, /`packages\/connector-mysql`/);
- assert.match(rootReadme, /`packages\/connector-postgres`/);
- assert.match(rootReadme, /`packages\/connector-snowflake`/);
- assert.match(rootReadme, /`packages\/connector-sqlite`/);
- assert.match(rootReadme, /`packages\/connector-sqlserver`/);
- assert.match(rootReadme, /`python\/ktx-sl`/);
- assert.match(rootReadme, /`python\/ktx-daemon`/);
+ assert.match(contributing, /cli\/\s+# CLI entry point/);
+ assert.match(contributing, /context\/\s+# Core context engine/);
+ assert.match(contributing, /llm\/\s+# LLM client abstraction/);
+ assert.match(contributing, /connector-bigquery\/\s+# BigQuery connector/);
+ assert.match(contributing, /connector-clickhouse\/\s+# ClickHouse connector/);
+ assert.match(contributing, /connector-mysql\/\s+# MySQL connector/);
+ assert.match(contributing, /connector-postgres\/\s+# PostgreSQL connector/);
+ assert.match(contributing, /connector-snowflake\/\s+# Snowflake connector/);
+ assert.match(contributing, /connector-sqlite\/\s+# SQLite connector/);
+ assert.match(contributing, /connector-sqlserver\/\s+# SQL Server connector/);
+ assert.match(contributing, /ktx-sl\/\s+# Semantic layer/);
+ assert.match(contributing, /ktx-daemon\/\s+# Daemon/);
});
it('documents every standalone MCP tool that the CLI server exposes', async () => {
- const rootReadme = await readText('README.md');
+ const servingAgents = await readText('docs-site/content/docs/guides/serving-agents.mdx');
- assert.match(rootReadme, /`connection_list`/);
- assert.match(rootReadme, /`knowledge_search`/);
- assert.match(rootReadme, /`knowledge_read`/);
- assert.match(rootReadme, /`knowledge_write`/);
- assert.match(rootReadme, /`sl_list_sources`/);
- assert.match(rootReadme, /`sl_read_source`/);
- assert.match(rootReadme, /`sl_write_source`/);
- assert.match(rootReadme, /`sl_validate`/);
- assert.match(rootReadme, /`sl_query`/);
- assert.match(rootReadme, /`ingest_trigger`/);
- assert.match(rootReadme, /`ingest_status`/);
- assert.match(rootReadme, /`ingest_report`/);
- assert.match(rootReadme, /`ingest_replay`/);
+ for (const tool of [
+ 'connection_list',
+ 'connection_test',
+ 'knowledge_search',
+ 'knowledge_read',
+ 'knowledge_write',
+ 'sl_list_sources',
+ 'sl_read_source',
+ 'sl_write_source',
+ 'sl_validate',
+ 'sl_query',
+ 'scan_trigger',
+ 'scan_status',
+ 'scan_report',
+ 'scan_list_artifacts',
+ 'scan_read_artifact',
+ 'ingest_trigger',
+ 'ingest_status',
+ 'ingest_report',
+ 'ingest_replay',
+ 'memory_capture',
+ 'memory_capture_status',
+ ]) {
+ assert.match(servingAgents, new RegExp(`\`${tool}\``));
+ }
});
- it('walks through ktx connection list and ktx connection test in the README quickstart', async () => {
- const rootReadme = await readText('README.md');
+ it('walks through connection testing in the quickstart and CLI reference', async () => {
+ const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx');
+ const connectionReference = await readText('docs-site/content/docs/cli-reference/ktx-connection.mdx');
- assert.match(rootReadme, /connection list --project-dir/);
- assert.match(rootReadme, /connection test warehouse --project-dir/);
- assert.match(rootReadme, /Driver: sqlite/);
- assert.match(rootReadme, /Tables: 1/);
+ assert.match(connectionReference, /ktx connection list/);
+ assert.match(connectionReference, /ktx connection test my-warehouse/);
+ assert.match(quickstart, /Connection test passed/);
+ assert.match(quickstart, /Driver: PostgreSQL .* Tables: 42/);
});
- it('documents public npm and managed runtime usage in the README', async () => {
+ it('documents public npm and managed runtime usage', async () => {
const rootReadme = await readText('README.md');
+ const quickstart = await readText('docs-site/content/docs/getting-started/quickstart.mdx');
+ const packageArtifacts = await readText('examples/package-artifacts/README.md');
- assert.match(rootReadme, publicPackagePattern('npx {package} setup demo --no-input'));
- assert.match(rootReadme, publicPackagePattern('npx {package} sl query'));
- assert.match(rootReadme, publicPackagePattern('npm install {package}'));
assert.match(rootReadme, publicPackagePattern('npm install -g {package}'));
- assert.match(rootReadme, /ktx runtime install/);
- assert.match(rootReadme, /ktx runtime status/);
- assert.match(rootReadme, /ktx runtime doctor/);
- assert.match(rootReadme, /ktx runtime start/);
- assert.match(rootReadme, /ktx runtime stop/);
- assert.match(rootReadme, /ktx runtime prune --dry-run/);
- assert.match(rootReadme, /ktx runtime prune --yes/);
- assert.match(rootReadme, /KTX requires `uv` on `PATH`/);
- assert.match(rootReadme, /KTX doesn't download `uv` automatically/);
+ assert.match(quickstart, publicPackagePattern('npm install -g {package}'));
+ assert.match(quickstart, /ktx runtime install --feature local-embeddings --yes/);
+ assert.match(quickstart, /ktx runtime start --feature local-embeddings/);
+ assert.match(quickstart, /Install `uv`, run `ktx runtime doctor`/);
+ assert.match(packageArtifacts, /requires `uv` on `PATH`/);
+ assert.match(packageArtifacts, /ktx runtime status/);
+ assert.match(packageArtifacts, /ktx runtime doctor/);
+ assert.match(packageArtifacts, /ktx runtime prune --dry-run/);
+ assert.match(packageArtifacts, /ktx runtime prune --yes/);
assert.match(
- rootReadme,
- runtimeWheelPackagePattern(
- 'release\\s+artifact manifest contains the public npm tarball and the\\s+bundled `{package}`\\s+runtime wheel',
+ packageArtifacts,
+ new RegExp(
+ `artifact manifest contains the public \`${escapeRegExp(publicNpmPackageName())}\` npm tarball and the\\s+bundled \`${escapeRegExp(
+ runtimeWheelPackageName(),
+ )}\` runtime wheel`,
),
);
- assert.match(rootReadme, /source packages for\s+development, not public release artifacts/);
assert.match(rootReadme, /ktx serve --mcp stdio/);
assert.doesNotMatch(rootReadme, /uv run ktx-daemon serve-http/);
assert.doesNotMatch(rootReadme, /--semantic-compute-url http:\/\/127\.0\.0\.1:8765/);
@@ -232,14 +246,17 @@ describe('standalone example docs', () => {
assert.doesNotMatch(readme, /python -m ktx_daemon semantic-validate/);
});
- it('replaces the fake-ingest smoke with a ktx scan walkthrough in the README', async () => {
+ it('documents scan workflows in the docs site', async () => {
const rootReadme = await readText('README.md');
+ const buildingContext = await readText('docs-site/content/docs/guides/building-context.mdx');
+ const scanReference = await readText('docs-site/content/docs/cli-reference/ktx-scan.mdx');
- assert.match(rootReadme, /### Scan the demo warehouse/);
- assert.match(rootReadme, /scan warehouse --project-dir/);
- assert.match(rootReadme, /scan status --project-dir/);
- assert.match(rootReadme, /scan report --project-dir/);
- assert.match(rootReadme, /raw-sources\/warehouse\/live-database/);
+ assert.match(buildingContext, /ktx dev scan /);
+ assert.match(buildingContext, /ktx dev scan status /);
+ assert.match(buildingContext, /ktx dev scan report /);
+ assert.match(scanReference, /ktx dev scan \[options\]/);
+ assert.match(rootReadme, /raw-sources\//);
+ assert.match(rootReadme, /live-database\//);
assert.doesNotMatch(rootReadme, /Run a local ingest smoke test/);
assert.doesNotMatch(rootReadme, /ktx dev ingest run --project-dir/);
assert.doesNotMatch(rootReadme, /ktx ingest status --project-dir/);
diff --git a/scripts/relationship-orbit-verification.mjs b/scripts/relationship-orbit-verification.mjs
index 1c24a4e9..d1c97f56 100644
--- a/scripts/relationship-orbit-verification.mjs
+++ b/scripts/relationship-orbit-verification.mjs
@@ -62,7 +62,7 @@ function firstNonEmptyLine(...values) {
function parseArgs(argv) {
const options = {
connectionId: process.env.KTX_ORBIT_CONNECTION_ID ?? 'orbit',
- projectDir: process.env.KTX_ORBIT_PROJECT_DIR ?? defaultProjectDir,
+ projectDir: process.env.KTX_PROJECT_DIR ?? defaultProjectDir,
reportPath: defaultReportPath,
};
@@ -242,7 +242,7 @@ function orbitVerificationEnv(projectDir) {
export async function runOrbitVerification(options = {}) {
const connectionId = options.connectionId ?? process.env.KTX_ORBIT_CONNECTION_ID ?? 'orbit';
- const projectDir = options.projectDir ?? process.env.KTX_ORBIT_PROJECT_DIR ?? defaultProjectDir;
+ const projectDir = options.projectDir ?? process.env.KTX_PROJECT_DIR ?? defaultProjectDir;
const reportPath = options.reportPath ?? defaultReportPath;
const rootDir = options.rootDir ?? ktxRootDir;
const runner = options.runWorkspaceKtx ?? runWorkspaceKtx;
diff --git a/scripts/relationship-orbit-verification.test.mjs b/scripts/relationship-orbit-verification.test.mjs
index c7cdaffc..017b2518 100644
--- a/scripts/relationship-orbit-verification.test.mjs
+++ b/scripts/relationship-orbit-verification.test.mjs
@@ -115,6 +115,43 @@ describe('relationship Orbit verification helper', () => {
assert.match(writes[0].content, new RegExp(defaultProjectDir.replace(/[.*+?^${}()|[\]\\]/g, '\\$&')));
});
+ it('uses KTX_PROJECT_DIR for the Orbit verification project override', async () => {
+ const previousProjectDir = process.env.KTX_PROJECT_DIR;
+ const calls = [];
+
+ try {
+ process.env.KTX_PROJECT_DIR = '/tmp/orbit-project-from-env';
+
+ const result = await runOrbitVerification({
+ reportPath: '/tmp/orbit-report.md',
+ now: () => new Date('2026-05-07T10:00:00.000Z'),
+ mkdir: async () => {},
+ writeFile: async () => {},
+ runWorkspaceKtx: async (argv, options) => {
+ calls.push(argv);
+ if (argv[2] === 'report') {
+ options.stdout.write(successReportJson());
+ return 0;
+ }
+ options.stdout.write('KTX scan completed\nRun: scan-orbit-1\nConnection: orbit\n');
+ return 0;
+ },
+ });
+
+ assert.equal(result.projectDir, '/tmp/orbit-project-from-env');
+ assert.deepEqual(calls, [
+ ['dev', 'scan', 'orbit', '--enrich', '--project-dir', '/tmp/orbit-project-from-env'],
+ ['dev', 'scan', 'report', '--json', '--project-dir', '/tmp/orbit-project-from-env', 'scan-orbit-1'],
+ ]);
+ } finally {
+ if (previousProjectDir === undefined) {
+ delete process.env.KTX_PROJECT_DIR;
+ } else {
+ process.env.KTX_PROJECT_DIR = previousProjectDir;
+ }
+ }
+ });
+
it('extracts the run id from human scan output', () => {
assert.equal(extractRunId(`KTX scan completed\nStatus: done\nRun: scan-orbit-1\nConnection: orbit\n`), 'scan-orbit-1');
assert.equal(extractRunId('KTX scan completed without a run line\n'), null);