Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
22 changes: 22 additions & 0 deletions docs-site/content/docs/configuration/ktx-yaml.mdx
Original file line number Diff line number Diff line change
Expand Up @@ -120,6 +120,7 @@ context-source drivers share the map.
| `dbt` | Context source | `driver`, one of `source_dir` or `repo_url` | `branch`, `path`, `profiles_path`, `target`, `project_name` |
| `metricflow` | Context source | `driver`, `metricflow.repoUrl` | `metricflow.branch`, `metricflow.path`, `metricflow.auth_token_ref` |
| `notion` | Context source | `driver`, `auth_token_ref` | `crawl_mode`, `root_*_ids`, `max_*_per_run` |
| `confluence` | Context source | `driver`, `base_url`, `email`, one of `api_token` or `api_token_ref` | `space_keys` |
| `sigma` | Context source | `driver`, `client_id`, `client_secret_ref` | `api_url` |

### Warehouse drivers
Expand Down Expand Up @@ -347,6 +348,27 @@ connections:
| `max_knowledge_creates_per_run` | Max new wiki pages created per run (0-25). |
| `max_knowledge_updates_per_run` | Max existing wiki pages updated per run (0-100). |

### Confluence

```yaml
connections:
my-confluence:
driver: confluence
base_url: https://yourorg.atlassian.net
email: you@yourorg.com
api_token_ref: env:CONFLUENCE_API_TOKEN
space_keys:
- ENG
- PROD
```

| Field | Purpose |
|-------|---------|
| `base_url` | Confluence Cloud base URL (e.g. `https://yourorg.atlassian.net`). Required. |
| `email` | Atlassian account email used for Basic auth. Required. |
| `api_token` / `api_token_ref` | Literal API token or reference. Prefer the `_ref`. |
| `space_keys` | List of space keys to ingest. Omit to ingest all accessible global spaces. |

### Sigma

```yaml
Expand Down
51 changes: 49 additions & 2 deletions docs-site/content/docs/integrations/context-sources.mdx
Original file line number Diff line number Diff line change
@@ -1,6 +1,6 @@
---
title: Context Sources
description: Ingest semantic context from dbt, MetricFlow, LookML, Metabase, Looker, Notion, Sigma, and Google Drive.
description: Ingest semantic context from dbt, MetricFlow, LookML, Metabase, Looker, Notion, Confluence, Sigma, and Google Drive.
---

Context sources feed your existing analytics tooling into **ktx**. During ingestion, **ktx** extracts metadata from each source and uses a reconciliation agent to reconcile it with your existing semantic layer and knowledge base - preserving accepted edits rather than overwriting.
Expand All @@ -27,7 +27,7 @@ LookML uses top-level `repoUrl`, and MetricFlow uses nested

| Field | Required | Description |
|-------|----------|-------------|
| `driver` | Yes | Source connector: `dbt`, `metricflow`, `lookml`, `metabase`, `looker`, `notion`, `sigma`, or `gdrive` |
| `driver` | Yes | Source connector: `dbt`, `metricflow`, `lookml`, `metabase`, `looker`, `notion`, `confluence`, `sigma`, or `gdrive` |
| `source_dir` | For local file sources | Absolute or project-relative source directory |
| `repo_url` | For Git-hosted dbt sources | Git repository URL |
| `repoUrl` | For Git-hosted LookML sources | Git repository URL |
Expand Down Expand Up @@ -378,6 +378,51 @@ Create an integration at [notion.so/my-integrations](https://www.notion.so/my-in

---

## Confluence

Ingests pages from Confluence Cloud spaces as wiki context. Uses the Confluence REST API v2 to fetch page content and metadata, then synthesizes durable wiki items from the storage XML.

### What it provides

- Page titles, breadcrumbs, and hierarchy (parent–child relationships)
- Full page body in Confluence storage XML format
- Space keys, space names, and page URLs
- Version metadata for incremental sync

### Connection config

```yaml title="ktx.yaml"
connections:
my-confluence:
driver: confluence
base_url: https://yourorg.atlassian.net
email: you@yourorg.com
api_token_ref: env:CONFLUENCE_API_TOKEN # or api_token: "<literal token>"
space_keys: [ENG, PROD] # optional — omit to ingest all global spaces
```

### Authentication

| Method | Config |
|--------|--------|
| API token | `email` + `api_token_ref: env:CONFLUENCE_API_TOKEN` |

Generate an API token at [id.atlassian.net/manage-profile/security/api-tokens](https://id.atlassian.net/manage-profile/security/api-tokens).

### What gets ingested

- All `current`-status pages in the configured spaces (or all accessible global spaces when `space_keys` is omitted)
- Each page's full content in Confluence storage XML — the `confluence_synthesize` skill extracts durable wiki candidates from it
- Ingest is incremental: pages whose version number and timestamp are unchanged since the last run are skipped

### Notes

- `space_keys` is case-insensitive; `ENG` and `eng` both match the same space
- Large Confluence instances with thousands of pages may take several minutes on the first run; subsequent runs are fast due to incremental sync
- Raw storage XML is kept in the staged directory; only synthesized wiki items are written to the ktx knowledge base

---

## Sigma

Ingests data model definitions and workbook metadata from a Sigma workspace as semantic context. Uses the Sigma REST API to fetch data model specs and workbook summaries.
Expand Down Expand Up @@ -537,6 +582,8 @@ connections:
- Only Google Docs are ingested in v1; other file types (Sheets, Slides, PDFs) in the folder are skipped and recorded in the staged manifest
- The service account must be granted access to the target folder explicitly

---

## Common errors

| Error or symptom | Likely cause | Recovery |
Expand Down
131 changes: 131 additions & 0 deletions packages/cli/src/context/ingest/adapters/confluence/chunk.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,131 @@
import { readdir, readFile } from 'node:fs/promises';
import { join, relative } from 'node:path';
import type { ChunkResult, DiffSet, WorkUnit } from '../../types.js';
import {
type ConfluenceManifest,
type StagedPageFile,
confluenceManifestSchema,
stagedPageFileSchema,
STAGED_FILES,
} from './types.js';

interface LoadedBundle {
manifest: ConfluenceManifest | null;
pagesByPath: Map<string, StagedPageFile>;
allPaths: string[];
}

async function walkStagedDir(stagedDir: string): Promise<string[]> {
let entries;
try {
entries = await readdir(stagedDir, { withFileTypes: true, recursive: true });
} catch (err) {
if ((err as NodeJS.ErrnoException).code === 'ENOENT') return [];
throw err;
}
const paths: string[] = [];
for (const entry of entries) {
if (!entry.isFile()) continue;
const abs = join(entry.parentPath, entry.name);
paths.push(relative(stagedDir, abs).replace(/\\/g, '/'));
}
paths.sort();
return paths;
}

async function loadBundle(stagedDir: string): Promise<LoadedBundle> {
const allPaths = await walkStagedDir(stagedDir);
let manifest: ConfluenceManifest | null = null;
try {
const body = await readFile(join(stagedDir, STAGED_FILES.manifest), 'utf-8');
manifest = confluenceManifestSchema.parse(JSON.parse(body));
} catch {
manifest = null;
}

const pagesByPath = new Map<string, StagedPageFile>();
const pagesPrefix = `${STAGED_FILES.pagesDir}/`;
for (const path of allPaths) {
if (!path.startsWith(pagesPrefix) || !path.endsWith('.json')) continue;
try {
const body = await readFile(join(stagedDir, path), 'utf-8');
const parsed = stagedPageFileSchema.parse(JSON.parse(body));
pagesByPath.set(path, parsed);
} catch {
// Malformed file — skip.
}
}

return { manifest, pagesByPath, allPaths };
}

/** @internal */
export const PAGES_PER_UNIT = 30;

function emitBatches(
paths: string[],
pages: Map<string, StagedPageFile>,
perUnit: number,
allPaths: string[],
): WorkUnit[] {
const batches = Math.ceil(paths.length / perUnit) || 0;
const units: WorkUnit[] = [];
for (let i = 0; i < batches; i++) {
const batch = paths.slice(i * perUnit, (i + 1) * perUnit);
const spaceKeys = [
...new Set(batch.map((p) => pages.get(p)?.spaceKey).filter((k): k is string => Boolean(k))),
].sort();
const spaceLabel = spaceKeys.length > 0 ? spaceKeys.join(', ') : 'pages';
const rawFiles = [...batch].sort();
const rawFilesSet = new Set(rawFiles);
const suffix = batches > 1 ? `-${i}` : '';
const labelBase =
batches > 1
? `Confluence: ${spaceLabel} (${i + 1}/${batches})`
: `Confluence: ${spaceLabel}`;
units.push({
unitKey: `confluence-pages${suffix}`,
displayLabel: labelBase,
rawFiles,
peerFileIndex: allPaths.filter((p) => !rawFilesSet.has(p)).sort(),
dependencyPaths: [],
notes: `${batch.length} Confluence page${batch.length === 1 ? '' : 's'} from space${spaceKeys.length === 1 ? '' : 's'} ${spaceLabel}`,
});
}
return units;
}

interface ChunkOptions {
diffSet?: DiffSet;
}

export async function chunkConfluenceStagedDir(
stagedDir: string,
opts: ChunkOptions = {},
): Promise<ChunkResult> {
const bundle = await loadBundle(stagedDir);
if (!bundle.manifest) {
return { workUnits: [] };
}

const pagePaths = [...bundle.pagesByPath.keys()].sort();
const firstRunUnits = emitBatches(pagePaths, bundle.pagesByPath, PAGES_PER_UNIT, bundle.allPaths);

if (!opts.diffSet) {
return { workUnits: firstRunUnits };
}

const touched = new Set([...opts.diffSet.added, ...opts.diffSet.modified]);
const kept: WorkUnit[] = [];
for (const wu of firstRunUnits) {
const anyTouched = wu.rawFiles.some((p) => touched.has(p));
if (!anyTouched) continue;
const changedFiles = wu.rawFiles.filter((p) => touched.has(p));
const unchangedFiles = wu.rawFiles.filter((p) => !touched.has(p));
const deps = new Set([...wu.dependencyPaths, ...unchangedFiles]);
kept.push({ ...wu, rawFiles: changedFiles.sort(), dependencyPaths: [...deps].sort() });
}
const eviction =
opts.diffSet.deleted.length > 0 ? { deletedRawPaths: [...opts.diffSet.deleted].sort() } : undefined;
return { workUnits: kept, eviction };
}
47 changes: 47 additions & 0 deletions packages/cli/src/context/ingest/adapters/confluence/client-port.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,47 @@
import type { FetchContext } from '../../types.js';
import type { ConfluencePullConfig } from './types.js';

export interface ConfluenceSpaceSummary {
id: string;
key: string;
name: string;
type: string;
status: string;
}

export interface ConfluencePageSummary {
id: string;
title: string;
spaceId: string;
parentId: string | null;
status: string;
version: { number: number; createdAt: string };
_links: { webui: string };
}

export interface ConfluencePageBody {
id: string;
title: string;
spaceId: string;
parentId: string | null;
status: string;
version: { number: number; createdAt: string };
body: { storage: { value: string } };
_links: { webui: string };
}

export interface ConfluenceRuntimeClient {
readonly baseUrl: string;
listSpaces(opts?: { spaceKeys?: string[] }): Promise<ConfluenceSpaceSummary[]>;
listPagesInSpace(spaceId: string, opts?: { limit?: number }): Promise<ConfluencePageSummary[]>;
getPageWithBody(pageId: string): Promise<ConfluencePageBody>;
testConnection(): Promise<{ success: boolean; message?: string; error?: string }>;
cleanup(): Promise<void>;
}

export interface ConfluenceClientFactory {
createClient(
config: ConfluencePullConfig,
ctx: FetchContext,
): Promise<ConfluenceRuntimeClient> | ConfluenceRuntimeClient;
}
Loading
Loading