Agent Skills › Kaelio/ktx

Kaelio/ktx

GitHub

用于处理需从ktx数据库获取数据的分析任务,如数据探索、指标解释、记录查找及周期对比。通过发现、检查、解析业务值、规划、查询、验证及记忆吸收的七步工作流,确保数据查询的准确性与完整性。

16 skills 1,455

Install All Skills

npx skills add Kaelio/ktx --all -g -y
More Options

List skills in collection

npx skills add Kaelio/ktx --list

Skills in Collection (16)

用于处理需从ktx数据库获取数据的分析任务,如数据探索、指标解释、记录查找及周期对比。通过发现、检查、解析业务值、规划、查询、验证及记忆吸收的七步工作流,确保数据查询的准确性与完整性。
需要查询ktx连接数据库中的数据 回答涉及数据分析的问题 用户询问'有多少'、'展示一下'或'分解情况' 按值查找记录或探索表结构 比较时间段或解释指标
packages/cli/src/skills/analytics/SKILL.md
npx skills add Kaelio/ktx --skill ktx-analytics -g -y
SKILL.md
Frontmatter
{
    "name": "ktx-analytics",
    "description": "Use when answering a question that needs data from a ktx-connected database - investigating, analyzing, \"how many\", \"show me\", \"what's the breakdown of\", finding records by value, exploring tables, comparing periods, explaining metrics, or any data-analysis request. Triggers even when the user does not say \"analytics\"; if the answer requires querying a configured ktx connection, this skill applies."
}

ktx Analytics Workflow

You have access to ktx MCP tools for data discovery, semantic-layer analysis, raw read-only SQL, wiki context, and memory ingest. Follow this workflow.

1. **Discover** - call `discover_data` first to see what exists across wiki pages, semantic-layer sources, metrics, dimensions, raw tables, and columns. Returns refs only. 2. **Inspect top hits in parallel** - for each promising ref: - `kind: 'wiki'` -> `wiki_read` - `kind: 'sl_source'`, `kind: 'sl_measure'`, or `kind: 'sl_dimension'` -> `sl_read_source` - `kind: 'table'` or `kind: 'column'` -> `entity_details` - For tables you intend to query, sample a few rows (`entity_details` plus a small `sql_execution` sample) to confirm date encoding, null prevalence in join/filter keys, and the real enum values — see the `` Schema-discovery rules. 3. **Resolve business values** - if the user named a value such as "Acme Corp", "enterprise", or "status=shipped", call `dictionary_search` to find which column holds it. 4. **Plan the analysis** - identify the grain, metrics, dimensions, filters, time window, and expected row limits before querying. Confirm each filter/join column's real type before comparing it (see the `` Schema-discovery rules). **Write down the exact output-column list first** — enumerate, from the question, every column the answer must have (each requested metric/attribute; for every grouped or named entity BOTH its id and its name; every input to each derived value) and treat that list as the contract your final `SELECT` must match column-for-column. Decide this list *before* writing SQL, not after — building the projection to a pre-stated list is far more reliable than reviewing for omissions at the end. 5. **Query** - - Prefer `sl_query` when the semantic layer covers the question. - Use `sql_execution` only for questions the semantic layer does not cover. - Before writing raw `sql_execution` SQL against a connection, call `sql_dialect_notes` with its connection id to get that engine's FQTN, identifier-quoting, date, top-N, series/calendar, rolling-window, safe-cast, and JSON conventions. - When authoring raw SQL, apply the `` rules: build incrementally, keep window ordering deterministic, compute at full precision, and match the answer's grain to the question. 6. **Validate and explain** - sanity-check totals, filters, null handling, and time zones. **Always run the final completeness check before emitting:** re-read the question and confirm every requested output, each named entity's identity, each derived value's inputs, and the question's grain are all in the projection — see the `` Final completeness check. If a result is unexpectedly empty or its grain looks wrong, work through the `` Answer-completeness rules to diagnose. State the source tables or semantic-layer objects used. 7. **Capture durable learnings** - call `memory_ingest` whenever a turn produces something worth remembering (business rules, metric definitions, schema gotchas, recurring findings) **or** whenever the user asks you to remember something. Pass markdown in `content` including any source context the memory agent should weigh. Each call is a feedback loop; better notes today mean smarter `discover_data` and `wiki_search` results tomorrow. - Always run `discover_data` before writing SQL. Do not guess table names. - Prefer the semantic layer over raw SQL when both can answer the question; measures are the source of truth. - Read entity details before writing SQL against an unfamiliar table. Do not assume column names. - Treat `sql_execution` as read-only. Writes are rejected by the server. - Validate value mentions with `dictionary_search` instead of guessing case or spelling. Treat a `dictionary_search` miss as non-authoritative. The index is built from profile-sampled values, so a missing value may simply have been outside the sample. Follow up with `sql_execution` against the most plausible columns before concluding the value is absent. - `connectionId` scoping when `connection_list` shows multiple connections: - Always pass it: `entity_details`, `sl_read_source`, `sql_execution`. - Pass it when intent pins a warehouse, otherwise omit for unscoped discovery: `sl_query`, `discover_data`, `dictionary_search`. - `memory_ingest`: pass it for warehouse-specific knowledge (e.g. "in our warehouse"); without it the memory lands as wiki-only and cannot update the semantic layer. - Never pass it: `connection_list`, `wiki_search`, `wiki_read`, `memory_ingest_status`. - If scoping is required but intent is ambiguous, ask which warehouse before calling. - Show compact result tables for small outputs. For broad results, summarize the top findings and mention the applied limit. - Ask a concise clarification only when the metric, date range, entity, or grain is genuinely ambiguous and cannot be inferred from context.

<sql_craft> Heuristics for writing correct (not merely runnable) SQL. Each is a default plus the reason it holds on any database; apply judgment to the question and the data.

Schema discovery before writing SQL

  • Sample before you compose. Inspect representative rows of every table you will touch (entity_details plus a small sql_execution sample) to confirm date/time encoding (YYYYMMDD integer vs ISO text vs epoch), null prevalence in join/filter keys, and the real set of categorical/enum values. Assumptions about encoding and nullability are the most common source of silently-wrong filters.
  • Cast to the real type before comparing. Compare a column against a literal of its actual type in WHERE/JOIN. A string column compared to a numeric literal (or the reverse) can silently match nothing instead of raising an error.
  • Parse text-encoded numerics before doing math on them. When a column the question treats as a number is stored as text, sample its distinct values (the Sample before you compose habit) to learn the encodings actually present — unit suffixes (K/M/B), currency symbols, thousands separators, percent signs, and non-numeric sentinels (-, N/A, empty) — and never infer the format from the column name. Why: aggregated or compared as-is the text sorts lexically ('100' < '9') and a naive cast collapses formatted values to 0/NULL, so the query runs but the number is silently wrong instead of erroring.
  • Strip, scale, and cast in one early CTE. Strip currency/separator/percent characters, multiply by the suffix scale (K=10^3, M=10^6, B=10^9), map sentinels to 0 or NULL (by the Default by additivity rule below), then cast to a numeric type — all in a single early CTE so every layer above sees clean numbers. This is the meaning-is-numeric complement to Cast to the real type before comparing. Why: one clean conversion at the base keeps the lexical-sort-and-cast-to-0 failure out of every downstream layer.
  • Confirm the parse covered every value. After parsing, count the non-sentinel rows that failed to parse — a failed parse should surface as NULL, visible only with a failure-detecting cast from sql_dialect_notes (a plain CAST errors on some engines and on sqlite silently returns 0/partial, so an IS NULL check is meaningless there). Why: an encoding the sample missed would otherwise vanish into 0/NULL instead of being caught.
  • Parse code/dependency text by its real grammar, not one broad regex. When a question extracts imported/required/loaded packages or modules from stored source text or dependency manifests, parse by the language or format, not a single pattern: Java import/import static — drop the terminal class/member, keep the package path, and allow valid identifier segments with underscores and mixed case (e.g. com.planet_ink.coffee_mud); Python — handle both import a, b as c and from a.b import c, stripping aliases; R — handle library(...) and require(...); notebooks (.ipynb) — parse the JSON and read each cell's source lines before applying the language rules (never regex the raw notebook file, whose prose contains the words "import"/"from"); JSON/manifest files — PARSE_JSON and flatten the dependency object's keys (e.g. require). Strip comments/prose lines first and split multi-import lines so each declared dependency is counted once. Why: a single lowercase-segment regex silently drops real identifiers and matches prose, so the ranking is wrong though the query runs.
  • Decide the counting population explicitly when a table is deduplicated. If the source table is de-duplicated and carries a documented copy/occurrence count (e.g. a copies column = "repositories sharing this exact content"), the count grain is a real modeling choice: weight by that column only when the question's population is clearly the represented files/repositories; otherwise count the distinct stored rows. State which population the question names and match it — do not default to one silently. Why: on a deduplicated table COUNT(*) and SUM(copies) give different rankings, so the right metric depends on the population the question asks about, not on which is larger.
-- "Total trade volume" where value_text holds '1.2K', '3M', '$1,200', '-'.
-- WRONG: a naive cast collapses the formatted values ('1.2K'->1.2, '$1,200'->0,
-- '-'->0) instead of erroring, so the SUM comes back silently far too low.
SELECT SUM(CAST(value_text AS REAL)) AS total_volume FROM metrics;

-- RIGHT: strip symbols/suffixes, scale by the K/M/B suffix, map sentinels to 0, and
-- cast once in an early CTE; the SUM then runs over clean numbers.
WITH parsed AS (
  SELECT CASE WHEN value_text IN ('-', 'N/A', '') THEN 0
    ELSE CAST(REPLACE(REPLACE(REPLACE(REPLACE(REPLACE(value_text,
                '$', ''), ',', ''), 'K', ''), 'M', ''), 'B', '') AS DECIMAL(18, 4))
         * CASE WHEN value_text LIKE '%K' THEN 1000
                WHEN value_text LIKE '%M' THEN 1000000
                WHEN value_text LIKE '%B' THEN 1000000000 ELSE 1 END
    END AS volume
  FROM metrics
)
SELECT SUM(volume) AS total_volume FROM parsed;
  • Canonicalize observed URL-path variants before page-level analysis. When a question groups, filters, or sequences web pages by a path/url column, sample its distinct values first. If the data itself shows route-label variants — /route and /route/ for the same page context — define a canonical page-path expression in an early CTE and use it everywhere above that CTE: preserve / as root, strip trailing slashes only from non-root paths, and map an observed empty path to / only when the column is a URL path and the sampled rows show blank root-page events. Do not merge different route names (/input/regist/input), strip query strings/fragments/host/scheme, lowercase paths, or canonicalize at all when the question asks for the raw stored URL/path or for slash-vs-no-slash differences. Why: raw request logs routinely store the same user-visible page both with and without a trailing slash, so grouping or sequencing the raw labels silently splits one page into several — but inventing aliases the data doesn't show would just as silently merge distinct pages.

Composition

  • Build incrementally. Assemble complex queries one CTE at a time, checking each layer's output on a small sample before stacking the next; a wrong intermediate layer is far cheaper to catch early than to debug in the final number.
  • Avoid fan-out joins — the danger is cumulative. Any one-to-many hop on the path between a measure's owning table and the aggregate inflates that measure, even when the offending join sits several hops below the SUM/COUNT and is easy to miss. The fix is the single-hop one applied per measure-owning table along the whole chain: pre-aggregate each coarse-grained measure to its own grain in a CTE, then join the already-aggregated result.
  • Verify the grain holds across each join. As you compose, confirm a join you intend to be one-to-one / many-to-one did not change the grain you aggregate at — e.g. the row count (or the count of the aggregate's key) is unchanged across it. When a join is genuinely one-to-many, reach for the default fix (pre-aggregate to grain); for a pure count, COUNT(DISTINCT key) is an acceptable escape hatch. A SUM/AVG of a fanned-out measure must pre-aggregate — DISTINCT cannot de-duplicate a sum.
  • A join that only attaches a label must not drop rows — LEFT JOIN it, and key the aggregate on the fact column. Fan-out's mirror image is just as silent: when you join a dimension table only to fetch a display attribute (a name for an id, a category for a product), an incomplete dimension — and dimensions are routinely incomplete: trimmed catalogs, late-arriving rows, slowly-changing-dimension gaps — makes a plain inner JOIN quietly discard every fact row whose key has no parent, shrinking the counts, sums, and the universe over which any share / average / median is computed (a measure halves with no error and no empty result). Two guards: (1) inner-join a dimension only when you intend it as a filter — you want exactly the rows that have a parent — never merely to read a column off it; for pure enrichment use LEFT JOIN. (2) Key the aggregation and GROUP BY on the fact column (sales.prod_id), not the dimension column (products.prod_id), so an unmatched key yields a NULL label on its own row rather than dropping or collapsing it. Use the same row-count check as above, but for an enrichment join confirm the fact row count is unchanged (not merely un-inflated); if a dimension you only wanted a name from removed rows, that is the bug.
  • Source each filter, date, and measure from the table that OWNS it at the question's grain. When two joined fact tables carry similarly-named columns at different grains — a parent (one row per order: its status, placement created_at, num_of_item) and its child (one row per line item: line created_at, sale_price, cost) — read each predicate/measure from the table whose grain the question names, not from whichever is in scope after the join. "Orders that are Complete", "for each month of the orders", "the order's creation date" are order-grain, so the status filter and the month bucket come from the parent order row, even though the child also has status/created_at columns; line price and cost come from the child. Why: the parent's and child's copies of a column diverge (an item's placement month or status can differ from its order's), so anchoring an order-grain filter or calendar on the line table silently buckets/filters the wrong rows. The mirror at metric grain: never combine a parent-grain count with child rows after the join (e.g. num_of_item * SUM(line_price) once per line) — compute each measure at its own grain (sum line prices to the order, take num_of_item once per order) before combining.
-- "How many orders per region contain a returned item?" — count each order once.
-- WRONG: order_lines is joined to apply the line-level filter, which multiplies
-- orders; an order with two returned lines is counted twice, three joins below
-- the COUNT, where the inflation is easy to miss.
SELECT r.region_id, COUNT(*) AS n_orders
FROM regions r
JOIN stores s      ON s.region_id = r.region_id
JOIN orders o      ON o.store_id  = s.store_id
JOIN order_lines l ON l.order_id  = o.order_id
WHERE l.status = 'returned'
GROUP BY r.region_id;

-- RIGHT: collapse order_lines to one row per qualifying order first, then join up
-- so each order contributes exactly once.
WITH returned_orders AS (
  SELECT order_id FROM order_lines WHERE status = 'returned' GROUP BY order_id
)
SELECT r.region_id, COUNT(*) AS n_orders
FROM regions r
JOIN stores s           ON s.region_id = r.region_id
JOIN orders o           ON o.store_id  = s.store_id
JOIN returned_orders ro ON ro.order_id = o.order_id
GROUP BY r.region_id;
-- A pure count could also use COUNT(DISTINCT o.order_id); a SUM/AVG of an
-- order-level measure fanned out this way must pre-aggregate — DISTINCT can't
-- de-duplicate a sum.

Ordering & aggregation determinism

  • Make the ordering deterministic. Give every ranking/ordering window a complete tie-breaker by appending unique key column(s) to ORDER BY, so RANK/ROW_NUMBER/LAG results are stable instead of flickering between runs.
  • Order inside string/array aggregation. When concatenating rows into a delimited string or building an ordered array (GROUP_CONCAT / string_agg / array_agg), the element order is undefined unless you specify it — put an explicit ORDER BY on the aggregate. Be deliberate about collation: the default text sort is binary/case-sensitive (so 'BBQ' sorts before 'Bacon' because uppercase code points precede lowercase), which differs from a case-insensitive sort; pick the one the question implies and apply it consistently (ORDER BY ... COLLATE NOCASE for case-insensitive). Why: an unordered or differently-collated concatenation produces a string with the right elements in the wrong order — runnable but not matching the expected text.
  • Emit a list-valued answer cell as a delimited STRING, not a raw ARRAY/repeated column. When the answer needs several values in one cell (a set of names/codes/tags for an entity), build a delimited scalar with STRING_AGG(x, ',' ORDER BY x) (or ARRAY_TO_STRING(ARRAY_AGG(x ORDER BY x), ',')) — do not return a SQL ARRAY/repeated column. Why: an array column serializes to an engine-specific representation (e.g. ['a' 'b'] or ["a","b"]) that won't compare equal to a plain delimited list (a,b), so a values-correct answer still mismatches when materialized to rows.
  • Filter after the window, not before, for sequence / "first" / "most recent" / "since" questions: compute the window over the full partition, then keep the rows you want. A pre-filter shrinks the partition the window ranks over, so "first"/"most recent" is measured against the wrong set.
-- "Each customer's first order, restricted to orders since 2024-01-01."
-- Wrong: the filter runs before the window, so it ranks only 2024 rows and
-- misses customers whose true first order was earlier.
SELECT customer_id, order_id,
       ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date, order_id) AS seq
FROM orders
WHERE order_date >= '2024-01-01';   -- then keep seq = 1

-- Right: rank the full partition in a CTE, then filter in the outer query.
WITH ranked AS (
  SELECT customer_id, order_id, order_date,
         ROW_NUMBER() OVER (PARTITION BY customer_id ORDER BY order_date, order_id) AS seq
  FROM orders
)
SELECT customer_id, order_id, order_date
FROM ranked
WHERE seq = 1 AND order_date >= '2024-01-01';
  • Cumulative / running total. Use an explicit frame — SUM(x) OVER (PARTITION BY k ORDER BY t ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) — with a complete tie-breaker on the ORDER BY (per the deterministic-ordering rule above). Why: a bare ORDER BY defaults to a RANGE-based frame bounded at the current row, which on ties in the order key folds every tied peer into one cumulative value — it runs and looks plausible, but the running total jumps at each tie boundary.
  • Rolling window over calendar time, plus minimum periods. "Rolling N days/months" spans a calendar range, not a fixed row count: a ROWS BETWEEN n-1 PRECEDING frame silently measures the wrong span when days are missing. Two sanctioned paths — (a) build a gap-free date spine first (the Series idiom from sql_dialect_notes) so one row exists per calendar unit, then a ROWS BETWEEN n-1 PRECEDING AND CURRENT ROW frame equals the intended span (fully portable); or (b) where the engine supports it, a native calendar range frame — or a date-keyed self-join — expresses the window directly: get the rolling-window idiom from sql_dialect_notes, do not inline it. For minimum periods ("only after N periods of data"), emit NULL until the window is full — guard on COUNT(*) OVER (<same frame>) = N, counting non-null observations instead when "N periods" means N data points rather than N calendar slots. Why: a row-count frame over missing dates measures the wrong span, and a partial early window is not the requested metric.
  • Period-over-period. Compare against the prior period with LAG(metric) OVER (PARTITION BY k ORDER BY period); compute growth as (cur - prev) / prev at full precision, rounding only in the final projection (per the round-at-the-end rule below), and guard the divide against a zero or absent prior — e.g. … / NULLIF(prev, 0). Why: without LAG, or ordered against the wrong neighbor, the comparison lands on the wrong period, and an unguarded ratio errors or returns garbage when the prior period is zero or missing.
-- "Each account's running balance over time" — a cumulative sum of net per
-- account, in date order.
-- WRONG: a bare ORDER BY defaults to a RANGE-based frame, so two txns dated the
-- same day share one inflated balance (every tied peer folds into that value).
SELECT account_id, txn_date, net,
       SUM(net) OVER (PARTITION BY account_id ORDER BY txn_date) AS running_balance
FROM account_txns;

-- RIGHT: an explicit ROWS frame accumulates row by row, and a complete tie-breaker
-- (txn_id) makes the order — and the running total — deterministic across ties.
SELECT account_id, txn_date, net,
       SUM(net) OVER (PARTITION BY account_id ORDER BY txn_date, txn_id
                      ROWS BETWEEN UNBOUNDED PRECEDING AND CURRENT ROW) AS running_balance
FROM account_txns;

Numeric precision

  • Integer division truncates on postgres/sqlite/tsql. The / operator between two integers does integer division on postgres, sqlite, and SQL Server5 / 2 is 2, wins / games is 0 — so a rate, share, or SUM(a) / COUNT(*) silently floors to an integer. Cast one operand to a fractional type before dividing: wins * 1.0 / games, CAST(wins AS REAL) / games, or SUM(a)::numeric / COUNT(*), then round at the end. mysql and bigquery already return a fractional result from / (on bigquery prefer SAFE_DIVIDE to also guard a zero denominator).
  • Round only at the end. Compute at full precision and round in the final projection, never inside intermediate CTEs. Be explicit about truncation: an integer cast (CAST(x AS INT)) truncates toward zero, so use explicit rounding when rounding is what you mean.
  • Macro vs micro average. Match the average to the wording. "Average of per-group averages" is AVG(group_metric); an "overall" or "weighted" average is SUM(numerator) / SUM(denominator). The two diverge whenever group sizes differ.

Answer completeness / interpretation

  • "Top / highest / most / lowest" returns only the winning row(s) — keep the top-ranked row from the window result — not the full ranked list, unless the question asks for a list.
  • "For each X / per X / by X" returns exactly one row per X. Do not collapse to a single value unless the question says "overall" or "total across X".
  • A named business measure means its amount, not a row count. When a question asks for "sales", "revenue", "spend", "value", or "volume" of money/goods without an explicit "number / count of", aggregate the monetary/quantity amount (SUM(price) / SUM(amount)), not COUNT(*) of rows. Why: "toy sales" reads as sales revenue; counting order rows silently answers a different question.
  • Answer literally — do not add unrequested transformations. Apply exactly the filters, joins, grouping, and computation the question (and any external_knowledge doc) states; do not add "helpful" extras the task never asked for — extra status/category predicates, area/residential weighting of an average the question states plainly, entity-name normalization that forces joins the source leaves unmatched, or a re-derived value where the question names a specific stored measure/column. When the wording bounds an aggregate ("committees whose total is between $0 and $200", "entities with 5+ orders"), filter the aggregate with HAVING, not each row with WHERE. When an external_knowledge doc gives an explicit formula or function/UDF definition, implement it verbatim — same operators, constants, and ordering — rather than substituting your own "more correct" math. Why: each unrequested predicate silently drops valid rows, each unrequested weighting/normalization or re-derivation changes the value, and a row-level filter for an aggregate bound answers a different question — so a more-sophisticated-looking query is wrong against the literal ask. Prefer the simplest reading that satisfies the question.
  • Don't project free-text columns the question didn't ask for. A description/body/comment/notes column whose values contain commas or newlines corrupts the row-delimited output and is almost never the requested value — leave it out of the final projection unless the question explicitly asks for it.
  • "Inter-event duration / gap / interval" is the time between consecutive events, not a magnitude. When the question asks the typical gap/interval/time between occurrences (releases, visits, orders), order rows by the event timestamp and take LEAD/LAG date differences, then aggregate — never a duration/length/runtime column.
  • Anchor a period bucket to the lifecycle event the wording names. When a record carries several lifecycle timestamps (created/placed, approved, shipped, delivered, completed, settled) and the question counts/measures records in a named completed state by period ("delivered orders by month", "shipped items per week", "completed payments by day"), bucket the period by that named event's own timestamp (order_delivered_customer_date, shipped_at, settled_at) — the state value is the qualifying filter, the matching timestamp is the time anchor. Use the creation/placed/purchased/submitted timestamp only when the question names that start event (purchased, placed, created, ordered, submitted) or no matching event timestamp exists. If several timestamps fit, pick the one for the event as experienced by the question's subject (customer delivery = the customer-receipt date, not the carrier-handoff or estimated date). If the named state is used only as a non-temporal filter (counts by customer/city/seller with no period bucket), it is just a filter — introduce no date anchor. Confirm each timestamp's meaning from column names, semantic-layer descriptions, and sample rows first. Why: bucketing a completed-state count by the record's creation date silently answers a different question — "records that later reached that state, grouped by when they started" — than the one asked.
  • "Highest / most across several achievements" aggregates per metric over the whole history. When a question asks for top values across multiple metrics or a career/lifetime total ("most runs, most wickets, longest span"), emit one row per metric with that metric summed/maxed over all the entity's records — not a single top-season or top-row snapshot.
  • An aggregate scoped to a per-entity selected set is computed across that set. "The average revenue per actor in those top-3 films", "the mean order value over each customer's last 5 orders" means, per entity, the aggregate over the items it selected — one value per entity spanning its chosen items — NOT the per-item value. The per-item formula the question gives ("divide film revenue among its actors") computes each item's contribution; the average/total then spans the selected items. When the question states both a per-item computation AND an aggregate over the items, compute and project BOTH (the per-item value and the across-set aggregate, e.g. AVG(item_value) OVER (PARTITION BY entity)). The set is chosen by the ranking measure the question names — "top-N revenue-generating films" ranks each entity's items by the item's own total revenue — and that ranking is independent of the per-item value (the share), which feeds only the aggregate, never the top-N selection.
  • Coverage over a selected group is a set-membership aggregate (one value for the whole group), not a per-entity metric. When a question first selects a group of entities ("the top 5 actors", "these products", "the eligible stores") and then asks what count/share/percentage of a different subject domain has any relationship to these selected entities ("what % of customers rented films featuring these actors"), the subject set is the UNION across the whole group: select the entity ids in a CTE, join to the subject facts, COUNT(DISTINCT subject_id) once across the group, and return one aggregate at the subject-domain grain (with the numerator/denominator projected if the question states a ratio). Counting the subject per selected entity and reporting N rows answers a different question and double-counts subjects that relate to more than one entity. This is the collective-coverage cousin of the per-entity rule above: emit one row per selected entity only when the wording says "for each / per / by / list" or asks for each entity's own metric ("top 5 players and their batting averages"); a bare "what share … of these" is one collective value.
  • Complete the panel for "each / every / all / per ". These cues mean the answer's rows should be the full expected domain — every month in the asked range, every region in the dimension — not only the groups that happen to have fact rows; a plain inner GROUP BY emits only non-empty groups, so empty periods/categories silently drop and a "12 months" answer comes back short. Build the full set of groups (the spine), LEFT JOIN the aggregated facts onto it, then default the gaps:
    • Spine source. For a category, take the distinct domain from the dimension/entity table (e.g. every region from regions) — not SELECT DISTINCT over the facts, which can only list categories that already occur; with no dimension table, distinct values from the unfiltered facts are the best available domain. For a period or number range, generate the series across the question's stated range (when the range is "all periods present", derive its bounds from MIN/MAX over the unfiltered facts). Series syntax is engine-specific — get the series/calendar idiom from sql_dialect_notes rather than inlining one dialect's generator.
    • Default by additivity. COALESCE(metric, 0) only for additive measures (a COUNT/SUM of events or amounts, where "no activity" genuinely reads as 0); leave non-additive measures (AVG, a rate, a ratio, a price, a running balance) as NULL — absence is "no data", and 0 would be a wrong reading.
    • Don't over-apply. each / every / all wants the complete domain; which / that have ("which months had orders") wants only the groups that exist — there the spine is wrong, so emit observed groups only.
    • Selecting the extreme group needs the spine too. When you pick the group with the highest/lowest count or total over a period/category domain ("the month with the lowest number of active customers", "the region with the fewest orders"), rank over the COMPLETE spine, not only groups that have fact rows — an empty period/category is a genuine 0 and is frequently the true minimum, yet ranking over observed groups alone silently makes it unselectable and returns the wrong extreme. A period with NO rows at all never appears in a GROUP BY of the facts: generate the full calendar of the stated range first ("each month of 2020" → all 12 months, even if only 4 have transactions), LEFT JOIN the per-group aggregates, COALESCE the count to 0, and only THEN rank — otherwise a zero-activity month that is the true lowest is invisible to the ranking.
  • Answer every requested output. When a question asks for several things — a list ("A, B, and C"), paired extremes ("the highest and the lowest"), or a value plus its components ("X, Y, and their ratio") — the projection needs one column per requested output, not just the first clause. Why: answering only the first clause is the most common way a runnable query is still wrong — the grain and methodology can be perfect yet the answer is short by columns. This is the umbrella over the next two rules: keep the inputs is its "value + components" case and expose identity is its "entity identity" case, so a complete projection is exactly every requested metric/attribute, plus the identifier of each named entity, plus the inputs to each derived value, at the question's grain. It governs which columns appear — distinct from Top … and For each X above, which govern which rows — and composes with them ("highest and lowest per region" needs one row per region and a column per clause).
  • Keep the inputs to a derived value. When the question asks for inputs and something derived from them ("X, Y, and their ratio"), project the inputs as columns alongside the derived value.
  • A comparison BETWEEN two specific extremes is one wide row. When the question asks for a single value derived by comparing two named extremes — "the difference between the highest and the lowest month", "the ratio of the best to the worst" — present BOTH extremes side by side in ONE row: each extreme's attributes as their own columns (e.g. highest_month, highest_value, lowest_month, lowest_value) plus the comparison as a column (difference). The comparison is a single fact about the pair, so the answer is one wide row — NOT one row per extreme with the comparison repeated. (Contrast: "report a metric for each group/category" — e.g. "a percentage for each helmet group", "the top player for each outcome" — has no cross-item comparison and stays long, one row per group.)
  • Project BOTH identity and label. When the result is per-entity, project the entity's identifier and its human-readable name together — whichever you grouped by, add the other. The id disambiguates duplicate names, and a consumer may legitimately expect either; supplying both is the safe, complete choice (a per-entity answer that gives only one is a frequent cause of an otherwise-correct result not matching).
  • Diagnose empty results. When a result is unexpectedly empty, relax filters one at a time to find which predicate removed the rows instead of guessing.
  • Spatial predicates ("within area / within N meters / inside this polygon / nearest"). When a question filters or relates rows by geography, use the engine's geospatial functions — get the exact ones from sql_dialect_notes — rather than hand-rolling latitude/longitude BETWEEN boxes (which are wrong off the equator and ignore polygon shape). Recipe: (1) turn each location into a geography point with the point constructor — mind argument order, most take longitude before latitude; (2) for an area of interest build a polygon from its boundary/corner coordinates, closing the ring (first point repeated last); (3) test the relation with the engine's containment (contains/within), proximity (dwithin(g1,g2,meters)), or overlap (intersects) predicate. For "the features within the same area as entity X", first resolve X's own geometry in a CTE, then join candidates on the spatial predicate against it. Why: spatial relationships are not axis-aligned ranges; the geodesic predicates are both correct and index-assisted, while a raw coordinate box silently includes/excludes the wrong rows.
  • Collapse a multi-valued attribute to one representative per entity before counting classes or a concentration metric. When an entity carries a multi-valued classification array (IPC/CPC codes, tags, categories) and the methodology counts entities per class or computes a concentration/diversity measure (HHI, originality, a share), pick exactly one representative value per entity in a CTE first — use the array's main/primary/first flag when present, else a defined fallback (e.g. the most-frequent value) — then aggregate. Equally, when a metric's denominator is defined as a count of entities ("the number of patents cited"), use COUNT(DISTINCT entity), not the count of exploded array rows. Why: LATERAL FLATTEN/unnest of the array multiplies an entity's weight by how many codes it has, inflating per-class frequencies and skewing any concentration metric — the query runs but the ranking/score is wrong. (Take the representative rule from the methodology/external_knowledge doc when it specifies one; do not invent a selection the source does not state.)
  • Final completeness check. Before emitting the final SQL, re-read the question and confirm the projection covers: (1) every named metric / attribute asked for (→ answer every requested output); (2) the identifier of each grouped or named entity (→ expose identity); (3) every input to each derived value (→ keep the inputs); (4) all at the grain the question specifies (→ for each X / complete the panel). Run this on every query, not only when a result looks off. Don't over-project: anything outside that set — a column the question never asked for, added "to be safe" — adds noise, misleads the reader into thinking it matters, and makes the result harder to consume. Match the request exactly: neither short nor padded.
-- "How many orders per region, including regions with no orders?" — every region
-- must appear, even one with zero orders.
-- WRONG: grouping the facts can only emit regions that have at least one order,
-- so a zero-order region silently drops and the panel comes back short a row.
SELECT region_id, COUNT(*) AS n_orders
FROM orders
GROUP BY region_id;

-- RIGHT: start from the full region domain (the dimension table), LEFT JOIN the
-- per-region counts onto it, and COALESCE the additive count to 0 so empty
-- regions read 0 instead of vanishing.
WITH region_domain AS (
  SELECT DISTINCT region_id FROM regions
),
region_orders AS (
  SELECT region_id, COUNT(*) AS n_orders
  FROM orders
  GROUP BY region_id
)
SELECT d.region_id, COALESCE(ro.n_orders, 0) AS n_orders
FROM region_domain d
LEFT JOIN region_orders ro ON ro.region_id = d.region_id;
-- "For each region, report the highest and the lowest monthly order count and the
-- difference between them." A complete answer is five columns: the region's id and
-- name, the highest, the lowest, and their difference.
-- WRONG: answers only the first clause and drops the region id, the lowest, and the
-- difference — four of the five requested columns are missing.
SELECT region_name, MAX(monthly_orders) AS highest
FROM region_monthly
GROUP BY region_name;

-- RIGHT: one column per requested output plus the entity's identity, at the region
-- grain — id and name, the highest, the lowest, and their difference.
SELECT r.region_id, r.region_name,
       MAX(rm.monthly_orders) AS highest,
       MIN(rm.monthly_orders) AS lowest,
       MAX(rm.monthly_orders) - MIN(rm.monthly_orders) AS order_count_range
FROM regions r
JOIN region_monthly rm ON rm.region_id = r.region_id
GROUP BY r.region_id, r.region_name;

</sql_craft>

**Input:** "How many orders did Acme Corp place last month?"

Workflow:

  1. dictionary_search({ values: ["Acme Corp"] }) finds customers.name.
  2. discover_data({ query: "orders customer monthly" }) finds an orders semantic-layer source.
  3. sl_read_source({ connectionId: "warehouse", sourceName: "orders_facts" }) confirms the source grain, measures, and dimensions.
  4. sl_query({ connectionId: "warehouse", measures: ["order_count"], filters: ["customer_name = 'Acme Corp'"] }) answers through the semantic layer.
  5. memory_ingest({ connectionId: "warehouse", content: "Acme Corp order analysis used orders_facts.order_count filtered by customers.name = 'Acme Corp'. Source: current analysis turn." }) captures the durable finding.

Input: "What columns does the events table have?"

Workflow:

  1. discover_data({ query: "events table" }) returns a table ref.
  2. entity_details({ connectionId: "warehouse", entities: [{ table: "analytics.events" }] }) returns columns, types, and foreign keys.
  3. Answer directly. No query is needed.

Input: "Heads up: ARR is always reported in cents in our warehouse."

Workflow:

  1. If multiple connections exist, call connection_list and identify the warehouse the user means. Ask if ambiguous.
  2. memory_ingest({ connectionId: "warehouse", content: "ARR is reported in cents (not dollars) in this warehouse. Multiply by 0.01 for dollar amounts. Source: user clarification." }) remembers the warehouse-specific rule without running an analysis turn.
将dbt YAML文件映射为ktx语义层覆盖层。处理模型/来源定义、列描述及数据测试(非空、唯一等)。需先通过仓库确认物理Schema,避免虚构列;若无法解析关联或列,则记录至Wiki而非生成无效源。
WorkUnit的skillNames包含dbt_ingest 检测到models/或sources/下的dbt YAML原始文件
packages/cli/src/skills/dbt_ingest/SKILL.md
npx skills add Kaelio/ktx --skill dbt_ingest -g -y
SKILL.md
Frontmatter
{
    "name": "dbt_ingest",
    "callers": [
        "memory_agent"
    ],
    "description": "Map dbt `schema.yml` \/ `properties.yml` models and sources into ktx semantic-layer overlays and column notes. Covers `sources:` vs `models:`, column `data_tests` (not_null, unique, accepted_values, relationships), and how bundle-time writes complement manifest backfill from git sync. Load when the WorkUnit's `skillNames` includes `dbt_ingest` or when raw files are dbt YAML under `models\/` \/ `sources\/`."
}

dbt → ktx (bundle ingest)

Use this skill for uploaded dbt projects (dbt_project.yml at stage root, models/**, sources/**, schema.yml). There is no fetch() in v1 - scheduled dbt parse / manifest.json pulls are out of scope; host-provided dbt sync may still backfill structured test metadata into _schema on the next sync.

Mapping (models / sources → SL)

dbt ktx Notes
models: entry with columns: Overlay on the manifest table with the same name (after discover_data / entity_details) One SL source per physical table; model name may differ from DB name - resolve with read_raw_file + warehouse context.
sources:tables: Same as models; use identifier when present instead of logical name. Schema + name must match how the connection sees tables.
Column description column_overrides[].descriptions.user on the overlay Do not overwrite dbt description keys from sync.
data_tests: not_null / unique Short hint in column descriptions or notes: “dbt: not null”, “dbt: unique” Full structured metadata lands in manifest via sync; the skill keeps bundle-time SL text useful for the agent.
accepted_values Add a brief line in the column description: allowed values (truncate long lists) Also mention enum-like use in discover_data / filters.
relationships Add or confirm joins: on the overlay only when to resolves to a real table via read_raw_file + discover_data / entity_details If the ref cannot be resolved, capture the intent in a wiki page instead.

Physical schema grounding

dbt YAML is documentation and test metadata; it is not permission to invent physical columns. Before writing any table-backed SL source, confirm the real warehouse shape with discover_data, sl_discover, or entity_details and use only confirmed column names in column_overrides:, computed-only columns:, grain:, joins:, segments:, and measures[].expr.

For dbt context-source ingest, the dbt connection is usually not the warehouse connection. Call sl_discover without connectionId first, then write overlays to the connection that owns the matching manifest-backed source (for example postgres-warehouse), not to the dbt connection (for example dbt-main). If no matching manifest-backed source is visible on any warehouse connection, do not call sl_write_source; record emit_unmapped_fallback and keep the fact wiki-only.

If a models: entry has no columns: block, or the available raw files do not confirm the physical column names, do not synthesize a full standalone source. Write a wiki note or a description-only overlay for the resolved manifest table instead. If a business metric is described but its referenced column is not confirmed in the warehouse schema, omit the measure and capture the unresolved intent in the wiki.

Include rawPaths on every wiki_write, sl_write_source, and sl_edit_source call with only the dbt YAML files that directly support the action.

After every sl_write_source, call sl_validate. A validation error saying a declared column or measure reference is absent from the physical table is a hard stop: re-read the warehouse-backed source and rewrite with confirmed names, or remove the invalid SL fields.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

1.1 test hints (descriptions / meta)

When YAML shows accepted_values or not_null, add short hints into column_overrides[].descriptions (for example under user) or freeform column notes so chat and validation see intent before the next git sync refreshes constraints / enum_values in _schema. Keep hints under a few words when possible.

Overlap with MetricFlow

If the same bundle also has MetricFlow semantic_models: / metrics:, the metricflow_ingest skill owns semantic/metric shapes. This skill focuses on raw dbt schema YAML (models, sources, tests). If both apply, load metricflow_ingest first when the file is clearly MetricFlow; otherwise use dbt_ingest for schema.yml without semantic_models.

Do not

  • Do not run dbt CLI or assume target/ / manifest.json exists in the upload.
  • Do not invent column names, grain keys, or measure expressions from dbt model names, descriptions, tests, or common naming patterns.
  • Do not write computed columns:, column_overrides:, grain:, or measures: for a dbt model unless those exact column names are confirmed by dbt YAML columns or warehouse schema discovery.
  • Do not invent joins from relationships tests if the target model/table is not found in SL or the warehouse.
  • Do not read peerFileIndex paths - use read_raw_file only on rawFiles and dependencyPaths from the WorkUnit.
从Google Drive文档中提取持久化知识,生成高质量Wiki页面。通过读取原始文件、搜索现有内容并整合证据,编写包含政策、术语等核心信息的条目,同时处理废弃路径决策,确保知识复用与准确性。
WorkUnit包含docs/下的Google Drive原始文件 需要从Google Docs提取结构化组织知识
packages/cli/src/skills/gdrive_synthesize/SKILL.md
npx skills add Kaelio/ktx --skill gdrive_synthesize -g -y
SKILL.md
Frontmatter
{
    "name": "gdrive_synthesize",
    "callers": [
        "memory_agent"
    ],
    "description": "Synthesize durable KTX wiki pages from staged Google Drive document pulls. Load when a WorkUnit contains Google Doc raw files from `docs\/**`."
}

Google Drive Doc Synthesis

Use this skill when a WorkUnit contains staged Google Drive content from docs/**.

Role

Each WorkUnit is one Google Doc plus its metadata. Read the assigned raw files, then write a small set of durable wiki entries that capture reusable organizational knowledge. Write final memory directly; do not write candidates.

Required Workflow

  1. Read the WorkUnit notes and rawFiles list. Document content lives in page.md; metadata.json holds title, path, url, modified time, and Drive folder context.
  2. For each assigned doc, call read_raw_file, or read_raw_span for oversized docs when the notes specify a span.
  3. Search wiki_search for existing pages that overlap the WorkUnit topics. Prefer updating an existing page over creating a duplicate.
  4. Use context_evidence_search, context_evidence_read, and context_evidence_neighbors when indexed document chunks would help reconcile related facts. Pass chunkId and documentId values verbatim as returned by the evidence tools.
  5. Write durable business knowledge with wiki_write. Aim for a small number of high-quality pages per doc. Include rawPaths with the exact Google Drive raw files that support each page.
  6. If a doc references warehouse, dbt, Looker, Metabase, or MetricFlow objects, you may verify them with discover_data, entity_details, sql_execution, sl_discover, or sl_read_source, but Google Drive docs are knowledge-only in v1. Do not create semantic-layer sources under the gdrive connection.
  7. For every deleted raw path in the Eviction Set, call eviction_list, decide retention, then emit_eviction_decision. Do this even when no wiki write is needed.

What To Capture

Capture durable, reusable company knowledge:

  • policies, workflows, process rules, ownership conventions, and operating procedures
  • product definitions, business terminology, and organizational guidance
  • source-of-truth statements, caveats, conflict notes, and supersession guidance
  • cross-system aliases that connect doc terminology to warehouse, dbt, Looker, Metabase, or MetricFlow names

Skip noisy or transient content:

  • brainstorming notes with no durable rule
  • task lists, meeting scheduling details, and time-bounded status updates
  • duplicate docs with no new fact
  • shallow summaries that add no reusable policy or definition

Quality

Prefer fewer, stronger entries. Every wiki entry must cite at least one Google Doc using its title or path and last modified date when available. When evidence conflicts, write a conflict note inside the wiki page rather than choosing silently.

If one doc covers several related ideas, synthesize the shared durable rules instead of writing one thin page per paragraph. For oversized spans, read only the assigned span unless the WorkUnit explicitly asks for neighboring context.

Search existing wiki pages for the same tables: or sl_refs: frontmatter and for source-of-truth aliases before creating a new page. If an existing page already documents the same warehouse object or business concept, update it instead of creating a differently named duplicate.

Citation Style

## Agentic Harness
- The harness provides the operational framework that turns an agent prototype into a production system.
- Source: Google Doc - Herness, last modified 2026-05-24.
- Conflict note: An older internal note uses a narrower definition focused only on tool wiring; treat the current Google Doc as the durable operating definition unless replaced explicitly.

Semantic-Layer Rules

  • Google Drive docs are knowledge-only in v1; keep durable output in wiki pages.
  • Do not create semantic-layer sources under the gdrive connection.
  • If a doc references an existing warehouse or semantic-layer object and you can verify it, you may attach sl_refs in wiki output after confirmation.
  • If a doc mentions a table or source that cannot be verified, keep the identifier in wiki text as unverified or use emit_unmapped_fallback only when the missing physical object itself is the important durable fact.

Identifier Verification Protocol

Before writing a wiki page on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the doc, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Tools

Allowed: read_raw_file, read_raw_span, wiki_search, wiki_read, wiki_write, discover_data, entity_details, sql_execution, sl_discover, sl_read_source, context_evidence_search, context_evidence_read, context_evidence_neighbors, emit_unmapped_fallback, eviction_list, emit_eviction_decision.

Not allowed: context_candidate_write, context_candidate_mark, sl_write_source, sl_edit_source, sl_validate.

从历史SQL模式分片中识别跨表分析意图,验证实体标识符后,生成标准化的模式证据对象。
处理 historic-sql 适配器的 patterns-input 分片文件 需要从原始数据中提取重复使用的跨表查询模式
packages/cli/src/skills/historic_sql_patterns/SKILL.md
npx skills add Kaelio/ktx --skill historic_sql_patterns -g -y
SKILL.md
Frontmatter
{
    "name": "historic_sql_patterns",
    "callers": [
        "memory_agent"
    ],
    "description": "Identify recurring cross-table historic-SQL analytical intents from a bounded pattern shard and emit typed pattern evidence for deterministic wiki projection."
}

Historic SQL Patterns

Use this skill when the WorkUnit raw file is a patterns-input/part-0001.json style shard from the historic-sql adapter. Older staged bundles may still provide root patterns-input.json; when that is the WorkUnit raw file, read it the same way.

Required Workflow

  1. Read the WorkUnit notes first.
  2. Find the single pattern input file listed under the WorkUnit rawFiles section.
  3. Call read_raw_file for that exact raw file path.
  4. Identify recurring analytical intents that span at least two tables and have repeated usage signal.
  5. Emit one pattern evidence object per durable cross-table intent by calling emit_historic_sql_evidence.
  6. Stop after all pattern evidence has been emitted.

Every join column mentioned in pattern descriptions must be verified via entity_details for both sides of the join.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Evidence Shape

Each call to emit_historic_sql_evidence must use this shape:

{
  "kind": "pattern",
  "pattern": {
    "slug": "order-lifecycle-analysis",
    "title": "Order Lifecycle Analysis",
    "narrative": "Analysts compare order statuses with customer segments to understand lifecycle movement.",
    "definitionSql": "select o.status, count(*) from public.orders o join public.customers c on c.id = o.customer_id group by o.status",
    "tablesInvolved": ["public.orders", "public.customers"],
    "slRefs": ["orders", "customers"],
    "constituentTemplateIds": ["pg:1", "pg:2"]
  }
}

The pattern object must match patternOutputSchema; multiple calls together must form patternsArraySchema.

Pattern Selection Rules

  • Prefer patterns that involve two or more tables.
  • Prefer templates with executionsBucket at least 10-100 and distinctUsersBucket above solo usage.
  • Merge templates into one pattern only when the business intent is the same.
  • Use a stable kebab-case slug based on intent, not a template id.
  • Set definitionSql to the clearest representative SQL from a constituent template.
  • Set slRefs to source names when the source name is obvious from table names; omit uncertain refs rather than guessing.
  • Treat each pattern shard independently; do not read peer shard files from peerFileIndex.

Boundaries

  • Do not call wiki_write.
  • Do not call sl_write_source.
  • Do not call sl_edit_source.
  • Do not call context_candidate_write.
  • Do not create single-table pattern pages.
  • Do not copy credentials, tokens, user emails, or unredacted literals into evidence.
将历史SQL表的原始数据转换为结构化的表使用证据,用于确定性的模式投影。通过解析JSON文件、验证标识符并调用emit_historic_sql_evidence工具,生成包含频率、过滤条件及连接信息的标准化叙事。
处理historic-sql适配器的tables目录下的单个JSON工作单元 需要将历史SQL查询记录转化为标准化的表使用元数据
packages/cli/src/skills/historic_sql_table_digest/SKILL.md
npx skills add Kaelio/ktx --skill historic_sql_table_digest -g -y
SKILL.md
Frontmatter
{
    "name": "historic_sql_table_digest",
    "callers": [
        "memory_agent"
    ],
    "description": "Convert one changed historic-SQL table usage bucket into typed table usage evidence for deterministic _schema projection."
}

Historic SQL Table Digest

Use this skill when the WorkUnit raw file is one tables/<schema>.<name>.json file from the historic-sql adapter.

Required Workflow

  1. Read the WorkUnit notes first.
  2. Call read_raw_file for the single tables/<schema>.<name>.json raw file.
  3. Read manifest.json only if the table JSON omits the dialect or the WorkUnit notes are unclear.
  4. Produce one concise usage narrative for this table from the staged table JSON.
  5. Call emit_historic_sql_evidence exactly once with kind: "table_usage".
  6. Stop after the evidence tool succeeds.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Evidence Shape

Call emit_historic_sql_evidence with this shape:

{
  "kind": "table_usage",
  "table": "public.orders",
  "usage": {
    "narrative": "Orders are repeatedly queried for paid/refunded lifecycle analysis and customer-level rollups.",
    "frequencyTier": "high",
    "commonFilters": ["status", "created_at"],
    "commonGroupBys": ["status"],
    "commonJoins": [{ "table": "public.customers", "on": ["customer_id"] }],
    "staleSince": null
  }
}

The usage object must match tableUsageOutputSchema.

Interpretation Rules

  • Treat columnsByClause.where as common filters.
  • Treat columnsByClause.groupBy as common group-bys.
  • Treat observedJoins as common joins.
  • Use stats.executionsBucket, stats.distinctUsersBucket, and stats.recencyBucket to choose frequencyTier.
  • Use frequencyTier: "high" only when executions and distinct users are both broad.
  • Use frequencyTier: "mid" for repeated team usage that is not broad enough for high.
  • Use frequencyTier: "low" for low-volume but present usage.
  • Use frequencyTier: "unused" only when the table input explicitly says the table is stale or has no recent templates.
  • Keep narrative short and concrete.

Boundaries

  • Do not call wiki_write.
  • Do not call sl_write_source.
  • Do not call sl_edit_source.
  • Do not call context_candidate_write.
  • Do not emit more than one table usage evidence object.
  • Do not invent columns, joins, or tables that are absent from the staged JSON.
用于在数据摄入阶段识别并解决冲突,包括结构重复、定义矛盾、近重复簇及重新摄入变更。通过决策树自动执行静默替换、标记或重命名等策略,确保数据一致性。
检测到与现有工件重叠的摄入内容 需要处理跨工作单元的重合或驱逐决策
packages/cli/src/skills/ingest_triage/SKILL.md
npx skills add Kaelio/ktx --skill ingest_triage -g -y
SKILL.md
Frontmatter
{
    "name": "ingest_triage",
    "callers": [
        "memory_agent"
    ],
    "description": "Classify and resolve conflicts detected during bundle ingest (structural duplicates, definitional contradictions, near-duplicate clusters, re-ingest changes, evictions)."
}

Ingest Triage - conflict classification and resolution

This skill is loaded in two contexts:

  • By a Stage 3 WorkUnit agent when sl_discover, deterministic projection output, existing project memory, or prior provenance overlaps with what the current WorkUnit is about to write.
  • By the Stage 4 reconciliation agent for cross-WorkUnit sweeps, accepted patch overlap, and eviction decisions.

Apply the rules below before every write that could collide with an existing artifact.

Decision tree

  1. Is this the same artifact I'm producing now, or a different one with the same name? Read both. If names match and content matches (modulo whitespace): no conflict - skip the write, the prior one stands.

  2. If content differs, is it an expression-only change (e.g. a different sql: body for the same measure name, same grain, same columns)? Re-ingest change (expression-only): silently replace via sl_edit_source. No flag.

  3. If the difference is structural - grain, columns, filter, join shape - is the current bundle the re-ingest of a previously-ingested bundle (i.e. priorProvenance has a row for this raw file and artifact)? Re-ingest change (semantic break): replace + flag. Record in the IngestReport's conflicts_resolved list with flagged_for_human: true.

  4. If reconciliation sees accepted patches from this same job with no prior-sync row, check for same-ingest contradictions:

    Kind Detection Resolution
    Structural duplicate Same name, near-identical expression Elect canonical by: (a) highest inbound-ref count from other sources; tiebreak: (b) lexicographically first unit key; (c) lexicographically first source name. Subsume losers into <canonical>-variants.md wiki page. Do NOT flag unless ambiguous.
    Near-duplicate cluster Different names, overlapping shape (same table, similar formulas) Same as structural; one canonical, others subsumed. Flag only if no canonical emerges.
    Definitional contradiction Same name, substantively different formulas (different aggregation, different filters, different columns) Rename + capture: disambiguate ALL variants with suffix derived from the domain (churn_risk_engagement_based, churn_risk_billing_based) and write a unified wiki page listing every variant with provenance. The contested name does NOT land in the SL. Always flag.
  5. Eviction (Stage 4 only): for each entry in eviction_list():

    • Remove the artifact (sl_write_source or sl_edit_source with delete: true for SL sources, wiki_remove for wiki pages).
    • Record the removal with emit_eviction_decision and action: "removed".

Why same-ingest vs re-ingest differs

Within ONE bundle there's no user signal telling us which duplicate wins - we capture all variants and flag. Across bundles, re-uploading IS the signal that the new state is intended - we replace silently for expression changes and flag for semantic breaks.

Naming disambiguation hints

When you rename to disambiguate, prefer domain suffixes that match the containing view/table/collection name: customers.churn_risk_scorecustomers.churn_risk_engagement_based (if the customer_churn view computes it from engagement); billing.churn_risk_scorebilling.churn_risk_billing_based. Avoid numeric suffixes (churn_risk_1, churn_risk_2) - they disclose nothing.

Applying canonical pins

When the Stage 4 system prompt includes a <canonical_pins> block, treat each pin as a prior user decision for that contestedKey.

  • If the pinned canonicalArtifactKey is present in the Stage Index or already exists in SL, keep it as the canonical artifact for that contested key.
  • Disambiguate competing artifacts instead of using the contested name for them.
  • Do not flag the pinned contested key solely because the variants disagree; the user has already chosen the canonical artifact.
  • If the pinned artifact cannot be found and no current WU can recreate it, emit emit_conflict_resolution with flaggedForHuman: true and explain that the pin references a missing canonical artifact.

When a pin applies cleanly, call emit_conflict_resolution with kind: "definitional_contradiction", artifactKey set to the pinned canonicalArtifactKey, detail describing the pinned election, and flaggedForHuman: false.

What to write in the unified wiki page

When you perform rename + capture, also write one page named <canonical-concept>-definitions.md under the wiki GLOBAL scope. Structure:

  • One heading per variant, referencing the disambiguated SL name.
  • One paragraph per variant: what it computes, where it came from (raw file + line range), when to use it.
  • A closing "Choosing between these" paragraph if the variants are legitimately domain-specific.

Do not attempt to rank variants or pick a "best" - that's user-override territory.

Silence rules

Flag for human review when:

  • You did rename + capture for a definitional contradiction (kind 3 above).
  • You performed an eviction retention (kind 5, second row).
  • An override constraint (from a Stage 4 re-run) conflicts with current inbound refs.

Do NOT flag:

  • Same-content duplicate skip (trivial).
  • Structural duplicate with clear canonical election.
  • Expression-only re-ingest replace.
从实时数据库快照捕获语义层和知识更新。读取原始表、连接及外键元数据,创建或更新语义层源,验证标识符并生成Wiki页面,最后执行校验以确保数据准确性与一致性。
工作单元包含 raw-sources/<connectionId>/live-database/<syncId>/ 下的原始文件 需要从实时数据库模式快照中提取语义层结构
packages/cli/src/skills/live_database_ingest/SKILL.md
npx skills add Kaelio/ktx --skill live_database_ingest -g -y
SKILL.md
Frontmatter
{
    "name": "live_database_ingest",
    "callers": [
        "memory_agent"
    ],
    "description": "Capture semantic-layer and knowledge updates from a live database schema snapshot."
}

Live Database Ingest

Use this skill when the ingest work unit contains raw files under raw-sources/<connectionId>/live-database/<syncId>/.

Workflow

  1. Read the table JSON file listed in the work unit.
  2. Read connection.json to understand the snapshot metadata.
  3. Read foreign-keys.json when the table has a foreign key or when joins are needed for the semantic-layer source.
  4. Create or update one semantic-layer source for the table with sl_write_source.
  5. Use the physical table name from the raw JSON as the source table field.
  6. Preserve database comments as descriptions.db on tables and columns.
  7. Add joins only when the foreign key index names both sides.
  8. Write wiki pages only for durable business meaning that is present in table or column comments.
  9. Run sl_validate for the table source before the work unit completes.

Sample values come from the scan record; do not invent values not present in relationship-profile.json.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Source shape

For a raw table with this shape:

{
  "name": "orders",
  "db": "public",
  "columns": [
    { "name": "id", "type": "integer", "nullable": false, "primaryKey": true }
  ]
}

Write a semantic-layer source with this shape:

name: orders
table: public.orders
grain: id
columns:
  - name: id
    type: number

Use string, number, time, or boolean for column types. When a database type is ambiguous, use string.

Boundaries

The raw snapshot is structural evidence. Do not invent measures, segments, business definitions, or joins that are not present in the snapshot files.

该技能用于从Looker运行时仪表板、查询和探索的JSON文件中提取持久化的知识及语义层贡献建议。通过读取原始文件及依赖项,结合现有知识库避免重复,利用特定工具将指标公式、字段语义等转化为可验证的Wiki候选或语义层写入,确保数据一致性与准确性。
需要处理Looker仪表板或查询的JSON数据时 需要从Looker运行时数据中提取并固化领域知识时 准备向语义层或Wiki写入新的指标定义或字段语义时
packages/cli/src/skills/looker_ingest/SKILL.md
npx skills add Kaelio/ktx --skill looker_ingest -g -y
SKILL.md
Frontmatter
{
    "name": "looker_ingest",
    "callers": [
        "memory_agent"
    ],
    "description": "Extract durable ktx knowledge and semantic-layer contribution proposals from staged Looker runtime dashboard, Look, and explore JSON. Load for WorkUnits whose raw files are under explores\/, dashboards\/, or looks\/."
}

Looker Runtime Ingest

Looker runtime ingest turns API-staged dashboards, Looks, and explores into durable ktx memory. Runtime entities are evidence. They are not themselves the final knowledge shape.

Required Workflow

  1. Read every rawFiles entry for the WorkUnit.
  2. Read relevant dependencyPaths before making a decision. For dashboard and Look WUs this usually includes the referenced explore JSON, signal files, folders/tree.json, and users/<id>.json.
  3. Treat signals/*.json, owners, folders, schedules, and favorites as prioritization or provenance context only.
  4. Extract generalizable metric formulas, segment definitions, field semantics, and domain conventions.
  5. Use wiki_search, sl_discover, and sl_read_source before writing so new content merges with existing memory instead of duplicating it.
  6. Use context_evidence_search or context_evidence_read to obtain evidence chunk IDs for any wiki-bound knowledge candidate.
  7. Use context_candidate_write for durable wiki-bound knowledge. Do not call wiki_write from a Looker WorkUnit; Stage 4 reconciliation promotes candidates and writes wiki pages.
  8. Use looker_query_to_sl for each Look query or dashboard tile query that has a query object.
  9. Write SL from Looker runtime evidence only through the staged warehouse target contract. For explores and inherited dashboard/Look queries, branch on targetTable.ok; when it is true, write on targetWarehouseConnectionId and use targetTable.canonicalTable as source.table. When it is false or missing, write wiki knowledge candidates and record emit_unmapped_fallback with the staged reason.
  10. Run sl_validate after every SL write. If validation fails, fix the source or roll it back before the WorkUnit ends.

For every Looker field reference, call entity_details on the underlying schema.table.column before promoting it to sl_refs or quoting it in wiki body.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Explore WorkUnits

Explore WUs have raw files like explores/<model>/<explore>.json and usually depend on lookml_models.json.

Use the deterministic API-derived source key:

looker__<model>__<explore>

For example, modelName: "b2b" and exploreName: "sales_pipeline" map to looker__b2b__sales_pipeline.

Mapped explore write shape:

{
  "connectionId": "22222222-2222-4222-8222-222222222222",
  "sourceName": "looker__b2b__sales_pipeline",
  "source": {
    "name": "looker__b2b__sales_pipeline",
    "table": "proj.dataset.opportunities",
    "grain": ["opportunity_id"],
    "columns": [
      {
        "name": "opportunity_id",
        "type": "string"
      },
      {
        "name": "arr",
        "type": "number"
      }
    ],
    "measures": [
      {
        "name": "total_arr",
        "expr": "sum(arr)"
      }
    ]
  }
}

Every concrete value in that example must be backed by raw Looker field SQL, source_tables preflight, source_columns, or existing SL when applied to a real WorkUnit. If the evidence is not present, write wiki candidates and emit emit_unmapped_fallback.

The staged explore file carries warehouse target fields populated before the WU starts:

  • connectionName: the Looker runtime connection name.
  • targetWarehouseConnectionId: the resolved warehouse connection id, or null when the Looker connection is unmapped.
  • rawSqlTableName: Looker's verbatim sql_table_name. Keep it as provenance only.
  • targetTable: the parsed target-table union. Use this as the sole branch condition.

When targetTable.ok === true, the explore has a complete ktx backing target. Before writing:

  1. Use targetTable.catalog, targetTable.schema, and targetTable.name for source_tables preflight matching through sl_discover or sl_read_source.
  2. Use Looker field sql, labels, descriptions, and type metadata to derive source columns, measures, segments, joins, and grain.
  3. Call sl_write_source or sl_edit_source with connectionId: targetWarehouseConnectionId and rawPaths set to the staged explore path.
  4. Set source.name to the deterministic API-derived source key, for example looker__b2b__sales_pipeline.
  5. Set source.table to targetTable.canonicalTable.
  6. Run sl_validate after every SL write.

The table field is targetTable.canonicalTable, not rawSqlTableName. Raw Looker values can contain aliases such as schema.table AS x, Looker templates such as ${TABLE}, or derived-table SQL. Those raw forms do not compose safely with SL generation. targetTable.canonicalTable is the dialect-quoted identifier rebuilt by the parser.

Use targetTable.{catalog,schema,name} only for source_tables preflight. Do not put those tuple fields separately into the SL source unless the SL schema already asks for them.

When targetTable.ok === false, keep the WU wiki-only for SL purposes. Capture durable domain semantics with context_candidate_write, then emit a fallback with the EXACT structured reason code from targetTable.reason. Put any human-readable context in clarification, NOT in reason:

{
  "rawPath": "explores/b2b/sales_pipeline.json",
  "reason": "no_connection_mapping",
  "clarification": "Looker connection b2b_sandbox_bq is not mapped to a warehouse connection",
  "fallback": "wiki_only"
}

Valid reason codes (use exactly one, no other strings allowed): no_connection_mapping, looker_template_unresolved, derived_table_not_supported, no_physical_table, multiple_table_references, unsupported_dialect, parse_error, missing_target_table.

When targetTable is null, read the raw explore file again. If the target is still absent, emit the same fallback with "reason": "missing_target_table".

Look And Dashboard WorkUnits

Looks have raw files like looks/<id>.json. Dashboards have raw files like dashboards/<id>.json. Dashboard tiles with inline query objects follow the same decision rules as Looks.

For each query:

  1. Call looker_query_to_sl with the query JSON, title, content type, and usage counts if available.
  2. Read the proposal's targetStatus, targetWarehouseConnectionId, targetTable, sourceTable, and canWriteStandaloneSource.
  3. If canWriteStandaloneSource is true, use targetWarehouseConnectionId for SL tools and sourceTable / targetTable.canonicalTable as the source table. Verify the proposal against the parent explore dependency and existing SL before writing.
  4. If the proposal decision is measure_added, add or edit a measure only after verifying the expression against the explore field SQL or an existing source column.
  5. If the proposal decision is source_created, create a source only when canWriteStandaloneSource is true and the filter is canonical. Use source.table = targetTable.canonicalTable.
  6. If targetStatus is unmapped, unparseable, or missing_target_table, keep SL wiki-only for this query and call emit_unmapped_fallback with the proposal's target reason or status.
  7. If the proposal decision is wiki_only, write a context candidate only when the Look or dashboard names a reusable business concept.

Capture Rules

Write SL for:

  • reusable aggregations with clear formulas;
  • reusable segment predicates that appear canonical;
  • calculated dimensions that are stable and backed by raw Looker query evidence;
  • joins or source relationships that are explicit in the explore JSON.

Write wiki for:

  • metric definitions in dashboard or Look titles, descriptions, axis labels, and filter semantics;
  • business meaning of an explore;
  • concept aliases used by teams;
  • caveats about multiple competing definitions.

Skip:

  • point-in-time values and chart screenshots;
  • dashboard layout, tile positions, colors, visualization types, and render settings;
  • owner names, top users, recipient counts, favorite counts, schedules, and usage counts as narrative content;
  • ad-hoc low-usage queries with no durable business semantics;
  • simple saved views of fields with no metric, segment, or concept definition.

Usage Signals

Use usage only to prioritize:

  • zero or near-zero usage lowers priority and often means skip;
  • high usage raises confidence that a metric or segment is canonical;
  • schedules and favorites can break ties between otherwise similar candidates.

When calling context_candidate_write, usage can affect scoring:

  • High usage (queryCount30d >= 10 or uniqueUsers30d >= 3) can justify authorityScore: 3 and reuseScore: 3 when the evidence is otherwise durable.
  • Zero recent usage should usually use actionHint: "skip" or lower reuseScore unless the content clearly defines a canonical business concept.
  • Schedules and favorites can raise reuseScore by 1 when deciding between otherwise similar candidate scores.

Never include the usage counts themselves in assertion, rationale, or eventual wiki prose.

Never write usage numbers, owner names, folder names, top users, schedule counts, or recipients into wiki article prose. If attribution is needed, keep it in provenance through the normal ingest action trail.

Provenance And Cross-References

When writing candidates from Looker evidence, cite chunk IDs from context_evidence_search or context_evidence_read. Stage 4 reconciliation writes wiki pages from promoted candidates and sets sl_refs when the source exists or was created in the run.

When an SL action is written on targetWarehouseConnectionId, the runner records targetConnectionId on the action and syncs knowledge_sl_refs to the warehouse connection. The wiki article still belongs to the Looker run connection; the SL ref belongs to the warehouse. Do not rewrite the source name or connection id in wiki frontmatter by hand. Use normal SL tool calls and let Stage 4 reconcile refs from actions.

Use these source-key conventions:

  • API-derived explore source: looker__<model>__<explore>
  • API-derived segment source: looker__<explore>__<slug>
  • File-adapter source, when present: <model>__<explore> without the looker__ prefix

During Stage 4 reconciliation, when both looker__<model>__<explore> and <model>__<explore> exist for the same connection, treat the unprefixed file-adapter source as canonical. Rewrite wiki sl_refs to the unprefixed source, remove the API-derived source if it was created in this run, and call emit_artifact_resolution with actionType: "subsumed", artifactKind: "sl", artifactKey: "looker__<model>__<explore>", and the raw explore path that produced it.

If a file-adapter source already exists and clearly subsumes the API-derived source, prefer the file-adapter source in sl_refs and mention the API entity only as evidence in the wiki content.

Examples

Measure proposal from a Look:

{
  "title": "Open Pipeline ARR",
  "query": {
    "model": "b2b",
    "view": "sales_pipeline",
    "fields": ["opportunities.arr", "opportunities.stage"],
    "filters": { "opportunities.stage": "open" }
  }
}

Expected handling:

  • call looker_query_to_sl;
  • verify opportunities.arr and opportunities.stage against the explore dependency and existing SL;
  • add or update a measure only if the resulting expression validates;
  • write wiki for the durable definition "open pipeline ARR" if it is not already captured;
  • avoid mentioning query counts or users in wiki prose.

Simple saved view:

{
  "title": "Accounts By Region",
  "query": {
    "model": "b2b",
    "view": "accounts",
    "fields": ["accounts.region", "accounts.segment"],
    "filters": {}
  }
}

Expected handling:

  • no SL write;
  • wiki only if the title or description defines a reusable company concept;
  • otherwise skip.
将LookML视图/模型转换为ktx语义层源。支持Overlay、Standalone等映射模式,处理类型转换与关联图。遇连接不匹配时拦截写入,确保数据一致性与准确性。
用户输入包含 .lkml 内容 需要将 LookML 配置映射到 ktx 语义层
packages/cli/src/skills/lookml_ingest/SKILL.md
npx skills add Kaelio/ktx --skill lookml_ingest -g -y
SKILL.md
Frontmatter
{
    "name": "lookml_ingest",
    "callers": [
        "memory_agent"
    ],
    "description": "Map a LookML view\/model\/explore into ktx semantic layer sources. Covers the LookML to ktx primitive table, provenance tagging, and three worked examples (overlay, standalone from derived_table, standalone with sql_always_where). Load when the turn contains `.lkml` content."
}

LookML to ktx Semantic Layer

LookML views map to SL sources, measure: to measures, explore: { join: } to the join graph. This skill lays out the mapping and the three capture shapes.

Mapping table

LookML ktx form Notes
view: X { sql_table_name: …; measure:/dimension:/join: } Overlay named X with measures, computed-only columns, column_overrides, joins, segments Manifest-backed; inherit grain/columns
view: X { derived_table: { sql: … } } Standalone with top-level sql:, explicit grain: + columns: No manifest entry exists
view: X { sql_always_where: <p> } Standalone with sql: SELECT * FROM <base> WHERE <p> Enforcement, not opt-in
explore: { join: Y { sql_on: …; relationship: … } } joins: entry { to: Y, on: "<local> = Y.<col>", relationship: … } On the overlay or standalone
conditionally_filter / always_filter segments: [{ name, expr }] Callers reference by name
Manifest entry _schema/*.yaml Never edit - auto-imported

Type map: date/datetime/timestamptime; yesnoboolean; numbernumber; stringstring. Ignore drill_fields: (UI only).

Decision rules

LookML writes target the run connection directly. Unlike Looker runtime ingestion, the LookML adapter is configured on the warehouse ktx connection, so do not look for targetWarehouseConnectionId and do not route through a mapping array.

Before any SL write, inspect the WorkUnit notes.

If notes contain:

[LOOKML SL WRITES DISALLOWED]
reason: lookml_connection_mismatch
...
[/LOOKML SL WRITES DISALLOWED]

this is a hard gate. The model's declared Looker connection: does not match the warehouse connection's configured expectedLookerConnectionName. Continue wiki extraction and context candidates. Do not call sl_write_source or sl_edit_source for that WorkUnit. The runner also removes those write tools for this WorkUnit; treat the missing tools as expected. Preserve the mismatch reason in any emit_unmapped_fallback you create.

When SL is allowed:

  • Overlay when the view is a thin wrapper over a manifest table (sql_table_name: matches a manifest entry). Do not repeat base columns or grain.
  • Standalone when the view uses derived_table: or sql_always_where:. sl_write_source rejects overlays whose name has no manifest entry; that error points here.
  • Skip a view with only view:, sql_table_name:, and bare dimension: entries (no measure:, description:, derived_table:, sql_always_where:, join:). The pre-filter already short-circuits those.
  • Include rawPaths on every sl_write_source/sl_edit_source call with the exact LookML raw file(s) that support the action.

Preflight: never guess column names

LookML's dimension_group: date { type: time; timeframes: [raw, date, week, month] } expands at Looker-render time into ${view.date_raw}, ${view.date_date}, ${view.date_week}, and so on. These are NOT physical warehouse columns. The physical column is whatever the group's sql: clause references (e.g. ${TABLE}.date → column date).

A prior replay hallucinated date_date, date_week into sql:, columns:, and grain: across 4+ standalones; every measure on each affected source returned 400 Unrecognized name: date_date at query time. Preventable.

Verify each sql_table_name from the LookML view with entity_details before mapping to an SL source.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Required flow before writing any overlay or standalone:

  1. Call sl_discover({ query: "<tableName>" }) for each base table you're about to touch. That returns the real columns.
  2. If the table isn't in the manifest, use the warehouse connectionId returned by discover_data or the target connection chosen from sl_discover, then call a dialect-appropriate SQL probe with that connection id, for example: sql_execution({connectionId: "warehouse", sql: "SELECT 1 FROM analytics.orders LIMIT 0"}). Replace warehouse, analytics, and orders with the verified connection, schema or dataset, and table from the WorkUnit evidence.
  3. Use only those names in sql:, columns:, and grain:. Map each dimension_group to ONE { name: <physical_col>, type: time, role: time } entry - never one per timeframe.
LookML input ktx columns: entry
dimension_group: month { type: time; timeframes: [month]; sql: ${TABLE}.month_date ;; } { name: month_date, type: time, role: time }
dimension_group: date { type: time; timeframes: [raw, date, week, month]; sql: ${TABLE}.date ;; } { name: date, type: time, role: time } - single entry, NOT date_raw/date_date/date_week

After every sl_write_source: call sl_validate. It runs SELECT * FROM (<your sql:>) LIMIT 0 against the connection. If a column name was invented, the warehouse's Unrecognized name: … error comes back verbatim. Treat that as a hard failure - re-read the real columns with sl_discover and rewrite.

Provenance markers

When a wiki mixes LookML source prose with sl_discover output, tag sections:

<!-- from: lookml -->
Customers fan out many-to-one into `accounts` via `account_id`.
<!-- /from -->
<!-- from: bq_schema -->
`customers.admin_user_id` is nullable - orphan rows exist.
<!-- /from -->

Invisible in most renderers; lets a future pass audit provenance.

Example 1 - overlay (thin wrapper)

LookML (excerpt):

view: fct_labs {
  sql_table_name: analytics.fct_labs ;;
  dimension: is_byol { type: yesno; sql: ${TABLE}.lab_type = 'byol' ;; }
  measure: count_lab_orders { type: count; description: "Total lab orders." }
  measure: count_byol_labs { type: count; filters: [is_byol: "yes"] }
}
explore: fct_labs {
  join: dim_customers { sql_on: ${fct_labs.admin_user_id} = ${dim_customers.admin_user_id} ;; relationship: many_to_one }
}

ktx overlay at <connId>/fct_labs.yaml:

name: fct_labs
descriptions:
  user: "Lab-order fact table. One row per lab order event."
columns:
  - name: is_byol
    type: boolean
    expr: "lab_type = 'byol'"
measures:
  - name: count_lab_orders
    expr: count(lab_order_id)
    description: Total lab orders.
  - name: count_byol_labs
    expr: count(lab_order_id)
    filter: "is_byol = true"
joins:
  - to: dim_customers
    on: "admin_user_id = dim_customers.admin_user_id"
    relationship: many_to_one

Example 2 - standalone from derived_table

view: lab_results {
  derived_table: { sql:
    SELECT lab_order_id, admin_user_id, lab_date, biomarker, value,
           value - LAG(value) OVER (PARTITION BY admin_user_id, biomarker ORDER BY lab_date) AS delta
    FROM analytics.raw_lab_results WHERE status = 'final' ;; }
  dimension: lab_order_id { primary_key: yes; type: string }
  measure: avg_delta { type: average; sql: ${delta} ;; }
}
name: lab_results
description: "Lab results with biomarker delta vs previous reading per user."
source_type: sql
sql: |
  SELECT lab_order_id, admin_user_id, lab_date, biomarker, value,
         value - LAG(value) OVER (PARTITION BY admin_user_id, biomarker ORDER BY lab_date) AS delta
  FROM analytics.raw_lab_results WHERE status = 'final'
grain: [lab_order_id]
columns:
  - { name: lab_order_id, type: string }
  - { name: admin_user_id, type: string }
  - { name: lab_date, type: time, role: time }
  - { name: biomarker, type: string }
  - { name: value, type: number }
  - { name: delta, type: number }
measures:
  - { name: count_lab_results, expr: "count(lab_order_id)" }
  - { name: avg_delta, expr: "avg(delta)" }

Example 3 - standalone with sql_always_where

view: rpt_daily_braze_email {
  sql_table_name: analytics.fct_email_sends ;;
  sql_always_where: ${TABLE}.channel = 'braze' AND ${TABLE}.status = 'delivered' ;;
  dimension: send_id { primary_key: yes; type: string }
  measure: delivered_count { type: count }
}
name: rpt_daily_braze_email
description: "Delivered Braze email sends (enforced filter: channel='braze', status='delivered')."
source_type: sql
sql: |
  SELECT * FROM analytics.fct_email_sends
  WHERE channel = 'braze' AND status = 'delivered'
grain: [send_id]
columns:
  - { name: send_id, type: string }
  - { name: admin_user_id, type: string }
  - { name: sent_at, type: time, role: time }
measures:
  - { name: delivered_count, expr: "count(send_id)" }

sql_always_where is enforcement → wrap into the sql:. Don't model it as a segment (segments are opt-in) or per-measure filter (fragile, duplicated).

将Metabase问题、模型和指标转换为ktx语义层源定义。涵盖结果元数据到KSL列类型的映射、主外键检测、去重、预聚合分解及连接图连通性分析,并处理历史同步状态。
WorkUnit包含cards/<id>.json文件 需要将Metabase卡片转换为ktx语义层定义
packages/cli/src/skills/metabase_ingest/SKILL.md
npx skills add Kaelio/ktx --skill metabase_ingest -g -y
SKILL.md
Frontmatter
{
    "name": "metabase_ingest",
    "callers": [
        "memory_agent"
    ],
    "description": "Convert Metabase questions, models, and metrics into ktx Semantic Layer source definitions. Covers result-metadata to KSL column type mapping, FK\/PK detection, near-duplicate deduplication, pre-aggregation decomposition, join-graph connectivity, and how to react to priorProvenance from earlier ingest syncs. Load when the WorkUnit contains `cards\/<id>.json` files under a Metabase bundle."
}

Metabase to ktx Semantic Layer

Each WorkUnit represents one Metabase collection's cards for one Metabase database (mapped to exactly one ktx connection). Every cards/<id>.json file carries the resolved SQL, result_metadata, card type, collection path, and referenced-card ids. The WU's sync-config.json tells you which sync mode is active and which selections apply. databases/<id>.json tells you the target ktx connection.

Context format

Each card JSON looks like:

{
  "metabaseId": 7,
  "name": "Daily orders",
  "description": "Orders by day",
  "type": "model",
  "databaseId": 42,
  "collectionId": 5,
  "resolvedSql": "SELECT ...",
  "templateTags": [{"name": "ref", "type": "card", "cardReference": 10}],
  "resultMetadata": [
    {"name": "day", "base_type": "type/DateTime", "semantic_type": "type/CreationTimestamp"},
    {"name": "order_count", "base_type": "type/Integer"}
  ],
  "collectionPath": ["Data", "Orders Team"],
  "referencedCardIds": [10]
}

Use resultMetadata to:

  • Map base_type to KSL column type: type/Integer, type/Float, type/Decimal, type/BigIntegernumber; type/Text, type/TextLikestring; type/DateTime, type/Date, type/DateTimeWithTZtime; type/Booleanboolean.
  • Identify grain candidates: columns with semantic_type: type/PK.
  • Identify join candidates: columns with semantic_type: type/FK plus fk_target_field_id.
  • Identify time columns: semantic_type: type/CreationTimestamp or type/UpdatedTimestamp → set role: time.
  • Use display_name for measure descriptions when available.

Additional card metadata

  • parameters: list of card-level parameters with widget types and defaults. When SQL resolution fell back to unresolved SQL, use this to drive Step A of the SQL-translation workflow (drop optional clauses): knowing each {{ var }} is type: "date/range" vs type: "category" tells you what kind of clause it is.
  • resultMetadata[i].field_ref: Metabase's canonical reference to the source warehouse field. Shape ["field", <field_id>, <options>]. When this is set, the column maps directly to a warehouse field, which is useful for declaring joins from FK metadata without re-parsing SQL.
  • lastRunAt: ISO timestamp of the card's last execution. If null or very old, the card may be dead; prefer skipping over creating a source.
  • dashboardCount: number of dashboards referencing the card. Cards with dashboardCount: 0 and a stale lastRunAt are strong skip signals.

Before writing a wiki page derived from a Metabase question SQL, verify each schema.table.column mentioned with entity_details.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Decision tree

For each card:

  1. Analyze resolvedSql + resultMetadata: identify base tables, aggregations, joins, filters, column types.
  2. REQUIRED before any write: call sl_discover for every candidate target source name. The response tells you whether the name is manifest-backed (Type: table or Type: sql). For manifest-backed names you MUST use the overlay shape (name: plus overlay fields such as measures:, segments:, descriptions:, joins:, disable_joins:, column_overrides:, and computed-only columns: entries with expr + type; no sql:, table:, grain:, or base-table columns:); the tool will reject a standalone write and you'll have wasted the call. If sl_discover returns nothing for the name, you can write a standalone source. Also call sl_read_source on existing sources you intend to extend so you don't duplicate measures.
  3. Include rawPaths: ["cards/<id>.json"] on every sl_write_source, sl_edit_source, and wiki_write call. If one artifact generalizes multiple near-duplicate cards, include each contributing card path and no unrelated cards.
  4. Decide:
    • Simple aggregation on a table that already has a source → sl_edit_source to add a measure.
    • Join between tables that should be linked in the SL graph → sl_edit_source to add a join.
    • Complex derived SQL (CTEs, multi-layer aggregation, scoring models) → sl_write_source with source_type: sql. When the SQL projects/filters from a single manifest-backed base table, set inherits_columns_from: <manifest_key> so columns inherit type and description from the manifest - see sl_capture skill for the slim form. Use sl_discover to discover the manifest key from the table reference in the SQL (it accepts MARTS.CONSIGNMENTS, ANALYTICS.MARTS.CONSIGNMENTS, or CONSIGNMENTS).
    • New base table not yet in the semantic layer → sl_write_source with source_type: table.
    • Trivial query (SELECT *, simple COUNT(*) with no business logic) → do nothing; the runner will record this card as action_type='skipped'.
    • Duplicate of an existing measure → same as trivial; do nothing for this card.

Manifest-only names need an overlay first. If sl_discover shows a source name with Type: table but sl_read_source returns "Source not found", the source lives only in the schema manifest (no standalone overlay yet). sl_edit_source cannot edit manifest-only names, and a full standalone sl_write_source for that name would shadow manifest columns and joins. Bootstrap an overlay with sl_write_source using the overlay shape:

name: <SOURCE_NAME>
measures:
  - name: <measure_name>
    expr: "<expression>"

Overlay shape: name: plus any of measures:, segments:, descriptions:, joins:, disable_joins:, exclude_columns:, column_overrides:, or computed-only columns: entries with expr + type. Never include sql:, table:, grain:, or base-table columns: on a manifest-backed name — those would shadow the manifest's schema and drop its joins. Use column_overrides: for inherited column descriptions. Overlay joins: are merged additively with the manifest's joins (deduped by to + on); use disable_joins: ["<on-clause>"] to suppress a specific manifest join. After the overlay exists, use sl_edit_source for further tweaks. See sl_capture skill for the canonical overlay rule.

Join discovery: When your card's SQL references warehouse tables (e.g. in FROM or JOIN clauses), call sl_discover({ query: '<table>' }) before writing. The matching manifest entry's name is the value you use in joins: [- to: <name>] only when the card output exposes a local key that matches the target source grain (for example account_id = mart_account_segments.account_id). Do not declare a ktx join just because the card SQL joins that table internally. If the output only exposes display fields such as account_name, keep the SQL source self-contained or project the key before adding the join. Use many_to_one for FK-to-dimension joins, one_to_many for the reverse.

Hard rule on join columns (prevents broken joins): For every join you declare, the local column on the left of on: MUST be (a) present in your source's projected output and (b) a key/ID column, never a display value. If the natural FK isn't in your SELECT, add it to SELECT before declaring the join. Joining account_name = mart_account_segments.account_id is always wrong - names are not identifiers and the equality produces zero matches. The validator rejects this with a "display value to identifier" error; the tool will refuse to save it. Add account_id to your SELECT and join on account_id = mart_account_segments.account_id, or omit the join entirely.

priorProvenance

If the WU prompt includes a priorProvenance section for a card, it tells you what happened on prior ingest syncs. Treat it as advisory:

  • action_type: source_created on source X → prefer editing X with sl_edit_source rather than writing a new source.
  • action_type: measure_added on source X → you already contributed to X; add only measures that aren't present.
  • action_type: subsumed or merged → this card was folded into another source last time; unless its SQL has changed structurally, keep it subsumed (no new write).
  • action_type: skipped → last time we decided not to ingest this card; re-read the SQL and confirm the decision still holds. If the card now has non-trivial business logic, ingest it.

Deduplication

Before writing, scan all cards in this WU for near-duplicate groups - cards whose resolvedSql shares the same CTEs, base tables, joins, and aggregation structure but differs only in:

  • Trailing filters (e.g. date_trunc(week, date) vs date_trunc(month, date)).
  • Minor WHERE clause variations.
  • Column aliases or output column subsets.
  • Aggregation granularity (daily vs weekly vs monthly).

When you find a group of near-duplicates:

  1. Create ONE generalized source from the most comprehensive card in the group.
  2. Strip card-specific trailing filters from the SQL so the source covers all variants (e.g. keep daily grain instead of filtering to week/month).
  3. If each card had a distinct measure or filter, add them as separate measures on the single source.
  4. For all cards except the canonical one, do nothing - they'll be recorded as action_type='skipped' automatically by the runner.

Do NOT merge cards with fundamentally different business logic, even if they share CTEs.

Pre-aggregation decomposition

When a card's resolvedSql contains GROUP BY with aggregation functions (SUM, COUNT, AVG, …):

  1. Detect: simple aggregation on base tables/joins - SELECT with GROUP BY, no complex CTEs or window functions.
  2. Decompose: strip the GROUP BY and aggregation functions. Keep FROM, JOIN, and WHERE intact.
  3. Expose row-level columns: include the grouped-by columns AND the raw columns being aggregated (e.g. money_out instead of SUM(money_out) AS total_money_out).
  4. Define aggregations as measures: convert each aggregation into a KSL measure (e.g. sum(money_out)).
  5. Add joins: with FK columns now exposed, declare joins to dimension sources.

Exception: keep the pre-aggregated SQL when the query involves multi-CTE pipelines, window functions, or recursive logic where decomposition would lose business logic.

SQL translation from raw native to KSL

Every card carries a resolvedSql field. Check the staged card's resolutionStatus first:

  • resolutionStatus: "resolved" - {{#N}} references are inlined and [[ ... ]] optional clauses have been dropped locally. If the resolved SQL contains no other parameters the SQL is executable as-is. If the card had required (non-bracketed) {{ var }} placeholders, the SQL is prefixed with a placeholder-warning comment block listing every dummy substitution Metabase made - see "Step A" below.
  • resolutionStatus: "fallback" - Metabase failed to resolve. The SQL still contains {{#N}}, {{#N-name}} alias, {{ var }}, and [[ ... ]] syntax. Do the translation steps below before writing a source.

Step A - Handle dummy-substituted placeholders (resolved cards only)

When a card has a required {{ var }} outside any [[ ]] block, the resolver substitutes a dummy value purely so Metabase's parser will accept the query. The resulting SQL is prefixed with a comment like:

-- PLACEHOLDER_WARNING: this SQL was extracted from a Metabase card with
-- unbound template parameters. The placeholders below were substituted with DUMMY
-- values to satisfy Metabase's parser - they DO NOT represent intended filters.
-- Drop the corresponding clauses (or expose them as runtime SL filters) before
-- persisting this SQL as a semantic-layer source.
--   {{ auction_end }} (type=dimension, widget=date/all-options) → '2020-01-01~2020-12-31'
--   {{ status }} (type=text) → 'placeholder'
SELECT ...
WHERE start_date >= '2020-01-01' AND start_date < '2021-01-01' AND status = 'placeholder'

For each listed placeholder: locate the WHERE clause(s) in the SQL that reference the dummy literal and drop them, then strip the warning comment. SL chat-time filters compose narrowing predicates dynamically, so the source should represent the unfiltered dataset.

For fallback cards, dropping is simpler - the SQL still has the [[ ... ]] brackets and {{ var }} placeholders intact:

-- before:
WHERE 1=1
  [[AND {{ auction_end }} ]]
  [[AND status = {{ status }} ]]

-- after:
WHERE 1=1

Step B - Inline {{#N}} references (fallback cards only)

Resolved cards already have {{#N}} inlined for you. For fallback cards, each {{#N}} (or {{#N-some-slug}}) in the SQL refers to another card's resolvedSql. The referenced card is in the WU's rawFiles or dependencyPaths. Read it with read_raw_file, then inline its SQL.

If the reference has an alias (from {{#5996-listing-interactions}} tb), the outer SQL probably uses that alias (select tb.* ..., tb.column_name, etc.). When you inline, you must EITHER:

  1. Pick a single base table inside the inlined SQL and rename its alias to the outer alias. Useful when the inlined card is SELECT * FROM listings JOIN ... - set the LISTINGS alias to tb and tb.* keeps working in the outer query.
  2. Replace the outer alias references with explicit columns from the inlined SQL. Useful when the inlined card has multiple JOINs and tb.* is ambiguous.

Never leave the outer alias dangling: after inlining, grep your SQL for the outer alias name and rewrite or remove every reference. A leftover tb.* with no tb table is the most common failure mode here.

Step C - Inlining cleanup checklist

After Steps A and B, your SQL must:

  • Contain no placeholder-warning comment, no {{, }}, [[, or ]] characters anywhere.
  • Reference no aliases that aren't defined inside the SQL itself.
  • Be valid as a standalone subquery (the validator runs SELECT * FROM (your_sql) LIMIT 1).

If resolutionStatus: "fallback" and the SQL is still complex enough that you can't confidently translate it, skip the card rather than writing broken SQL. Call emit_unmapped_fallback with the staged card path as rawPath, reason: "parse_error", clarification: "metabase_sql_untranslated", and fallback: "flagged".

Join-graph connectivity

For source_type: table:

  • Use FK columns (semantic_type: type/FK) to declare many_to_one joins to dimension sources.
  • Match column names ending in _id against existing sources' grain columns.

For source_type: sql:

  • The validator parses your SQL and rejects the write when a referenced manifest table has a viable projected local key but no declared joins: entry. Add the join only after confirming the output key and target grain match.
  • If sl_discover resolves the table, it is not outside the manifest. Do not write an unmapped-table-* fallback for resolved orbit_raw, mart, or other manifest-backed sources just because they appear inside card SQL.
  • If sl_discover cannot resolve a referenced table at all, write a single-line wiki_write with key unmapped-table-<table_name> and rawPaths: ["cards/<id>.json"] so the gap is documented, then call emit_unmapped_fallback with the staged card path as rawPath, reason: "missing_target_table", tableRef: "<table_name>", and fallback: "wiki_only". Do not use this fallback if sl_discover resolved the table/source.

Joins on manifest-backed names compose: the manifest's joins are inherited automatically, and any overlay joins: are merged on top (deduped by to + on). Use disable_joins: ["<on-clause>"] in the overlay to suppress a specific manifest join. If sl_discover shows a manifest-backed source with Joins: 0 and the warehouse FK metadata is genuinely absent, declaring application-level joins via the overlay is fair game - bootstrap with sl_write_source (overlay shape above), then refine via sl_edit_source.

Cross-card references ({{#N}})

Resolved cards (resolutionStatus: "resolved") have these inlined for you. Unresolved cards (resolutionStatus: "fallback") need manual handling - see "SQL translation from raw native to KSL" above.

Provenance markers

Every SL source and wiki page you write carries HTML-comment provenance tags pointing to the cards/<id>.json files they derive from:

# <!-- from: raw-sources/<connId>/metabase/<syncId>/cards/7.json -->
name: orders
...

If a source is derived from multiple cards (e.g. a generalized source for a near-duplicate group), emit one tag per contributing card.

Quality standards

Source definitions must follow ktx-sl YAML conventions:

  • source_type: "table" (physical table/view) or "sql" (arbitrary SQL / derived view).
  • table: required when source_type: "table" (e.g. "public.orders").
  • sql: required when source_type: "sql".
  • grain: what one row represents (e.g. [id], [customer_id, product_id]).
  • columns: all columns with correct types (string, number, time, boolean).
  • Time columns: mark with role: time.
  • joins: use correct relationship types (many_to_one for FK→PK, one_to_many for reverse).
  • joins.on: local_column = TARGET_SOURCE.target_column - the right side MUST include the target source name.
  • measures.expr: aggregation expression (e.g. "sum(amount)"); optional filter for business rules; required description.

Measure naming: descriptive snake_case (e.g. total_revenue, avg_order_value).

Rules

  • Prefer adding measures to existing sources over creating new ones.
  • Before editing, always sl_read_source the source to check for existing measures.
  • Don't duplicate measures (same aggregation on the same column).
  • If two measures differ only by a filter (e.g. revenue vs paid_revenue), they are distinct.
  • Use the card's name + description to write meaningful measure descriptions.
  • When multiple cards in a WU are near-duplicates, create ONE generalized source; the runner will skip the rest automatically.
  • Process every card in the WU - don't stop early.
处理包含Notion原始文件或证据的工作单元,读取页面并搜索现有Wiki。综合生成高质量、可复用的业务知识条目(如指标、流程),或在适用时捕获语义层数据源。同时处理被删除路径的保留决策,避免重复和噪声内容。
工作单元包含 Notion 原始文件 工作单元包含聚类的 Notion 证据块
packages/cli/src/skills/notion_synthesize/SKILL.md
npx skills add Kaelio/ktx --skill notion_synthesize -g -y
SKILL.md
Frontmatter
{
    "name": "notion_synthesize",
    "callers": [
        "memory_agent"
    ],
    "description": "Synthesize durable ktx wiki pages and semantic-layer sources from staged Notion pages, databases, data-source rows, and clustered Notion evidence. Load when a WorkUnit contains Notion raw files or Notion evidence chunks."
}

Notion Cluster Synthesis

Use this skill when a WorkUnit contains staged Notion content from pages/**, databases/**, data-sources/**, or clustered Notion evidence.

Role

Each WorkUnit is either a single Notion page/span or a topical cluster of related Notion pages, pre-grouped by embedding similarity. Read the assigned raw files, then write a small set of durable wiki entries and, when applicable, semantic-layer sources that synthesize the WorkUnit's knowledge. Write final memory directly; do not write candidates.

Required Workflow

  1. Read the WorkUnit notes and rawFiles list. Page content lives in page.md; metadata.json holds title, path, object type, data-source ids, last edited metadata, and properties.
  2. For each assigned page, call read_raw_file, or read_raw_span for oversized pages when the notes specify a span.
  3. Search wiki_search for existing pages that overlap the WorkUnit topics. Prefer updating an existing page over creating a duplicate.
  4. Use context_evidence_search, context_evidence_read, and context_evidence_neighbors to pull supporting chunks when indexed evidence is relevant. Pass chunkId and documentId values verbatim as returned by the evidence tools.
  5. Write durable business knowledge with wiki_write. Aim for a small number of high-quality pages per WorkUnit or cluster. Include rawPaths with the exact Notion raw files that support each page.
  6. When the Notion content defines a reusable dataset, metric, segment, join rule, source-of-truth mapping, or table with explicit columns, load sl_capture, discover existing sources first with sl_discover or sl_read_source, then use sl_write_source or sl_edit_source only for a confirmed mapped non-Notion target source. Include rawPaths with the exact Notion raw files that support the SL action. If no mapped target exists, call emit_unmapped_fallback and keep the content wiki-only.
  7. For every deleted raw path in the Eviction Set, call eviction_list, decide retention, then emit_eviction_decision. Do this even when no wiki write is needed.

What To Capture

Capture durable, reusable company knowledge:

  • metric definitions, KPI formulas, named business concepts, and reusable filters
  • workflows, policies, ownership rules, approval conventions, and source-of-truth mappings
  • data-source row pages that describe tables, columns, semantic models, dashboards, or business entities
  • cross-system aliases connecting Notion terms to warehouse, dbt, Looker, Metabase, or MetricFlow names
  • caveats, conflicts, supersession notes, and customer/product assumptions affecting future analysis

Skip noisy or transient content:

  • meeting notes with no reusable rule
  • task lists, project status updates, and time-bounded snapshots
  • duplicate docs with no new fact
  • database metadata pages when row pages contain the actual business content
  • transient announcements and long page summaries

Quality

Prefer fewer, stronger entries. Every wiki entry must cite at least one Notion page or row using its path and last edited date when available. When evidence conflicts, write a conflict note inside the wiki page rather than choosing silently.

If a clustered WorkUnit includes several related pages, synthesize the shared rule or concept instead of writing one thin page per source. For oversized page spans, read only the assigned span unless the WorkUnit explicitly asks for neighboring context.

Search existing wiki pages for the same tables: or sl_refs: frontmatter and for source-of-truth aliases before creating a new page. If an existing page already documents the same warehouse object or business concept, update it instead of creating a differently named duplicate.

Citation Style

## Revenue Recognition
- Booked revenue excludes refunds and test accounts.
- Source: Notion - Company Handbook / Finance / Revenue Recognition, last edited 2026-04-12.
- Conflict note: An older Sales Ops page uses gross revenue before refunds; treat the Finance Handbook as current unless Finance says otherwise.

Semantic-Layer Rules

  • Load sl_capture before writing or editing SL sources.
  • Discover existing sources first with sl_discover; read existing source YAML before editing.
  • Prefer overlays on manifest-backed sources over standalone SQL.
  • If Notion describes a dashboard or metric but does not define executable logic, write a wiki page and attach sl_refs only after confirming the referenced source exists.
  • Notion dataSourceCount counts Notion databases/data sources only. It does not prove that a warehouse/dbt table has or lacks a mapped semantic-layer source.
  • Do not create SL sources under the Notion connection just because a page mentions a warehouse, dbt, Looker, or Metabase object. Use the mapped warehouse/source connection after discovery, or emit an unmapped fallback and write wiki-only.
  • Distinguish fallback reasons precisely: if a non-Notion warehouse/dbt connection exists but sl_discover cannot find the named table/source, use no_physical_table; reserve no_connection_mapping for cases where there is no plausible non-Notion target connection at all.
  • If sl_discover resolves the table/source, do not call emit_unmapped_fallback for that table. Use the resolved source for sl_refs, overlay edits, or wiki-only documentation.
  • When calling emit_unmapped_fallback, pass the table or source identifier as tableRef (e.g. tableRef: "<schema>.<table>") - the tool generates the canonical detail string from the reason code and tableRef. Use the optional clarification field only to add context that does not contradict the reason. Do not restate the reason in clarification.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Tools

Allowed: read_raw_file, read_raw_span, wiki_search, wiki_read, wiki_write, discover_data, entity_details, sql_execution, sl_discover, sl_read_source, sl_write_source, sl_edit_source, sl_validate, context_evidence_search, context_evidence_read, context_evidence_neighbors, emit_unmapped_fallback, eviction_list, emit_eviction_decision.

Not allowed: context_candidate_write, context_candidate_mark.

从暂存的Sigma数据模型规范和仪表板摘要中提取知识,生成持久的ktx wiki内容。适用于unitKey为sigma-data-models或sigma-workbooks的WorkUnits,利用已生成的语义层YAML进行知识整合。
用户请求处理Sigma数据模型知识入库 用户请求处理Sigma仪表板知识入库
packages/cli/src/skills/sigma_ingest/SKILL.md
npx skills add Kaelio/ktx --skill sigma_ingest -g -y
SKILL.md
Frontmatter
{
    "name": "sigma_ingest",
    "callers": [
        "memory_agent"
    ],
    "description": "Extract durable ktx wiki knowledge from staged Sigma data model specs and workbook summaries. Load for WorkUnits with unitKey sigma-data-models or sigma-workbooks."
}

Sigma Ingest

Sigma ingest turns staged data model specs and workbook summaries into durable ktx wiki knowledge. The deterministic project() step has already written semantic-layer YAML for all warehouse-table data model elements before this skill runs — do not re-write those SL sources.

Work unit structure

Sigma produces at minimum two work units per ingest run:

  • sigma-data-models or sigma-data-models-N
    • rawFiles: data-models/<id>.json files (one per data model in this batch)
    • peerFileIndex: workbooks/<id>.json files + sigma-manifest.json + sigma-projection-config.json
    • When the workspace has more than 50 data models, split into batches: sigma-data-models-0, sigma-data-models-1, … with displayLabel like "Sigma: data models (1/8)". When ≤50 data models, the unitKey is simply sigma-data-models with no suffix.
  • sigma-workbooks or sigma-workbooks-N
    • rawFiles: workbooks/<id>.json files (one per workbook in this batch)
    • peerFileIndex: data-models/<id>.json files + sigma-manifest.json + sigma-projection-config.json
    • When the workspace has more than 2000 workbooks, split into batches: sigma-workbooks-0, sigma-workbooks-1, … with displayLabel like "Sigma: workbooks (1/4)". When ≤2000 workbooks, the unitKey is simply sigma-workbooks with no suffix.

sigma-manifest.json and sigma-projection-config.json are never in rawFiles. They live at the staged dir root and always appear in peerFileIndex.

Staged file shapes

data-models/<id>.json — one per data model (in rawFiles for data-model units):

{
  "sigmaId": "abc-123",
  "name": "Revenue Model",
  "path": "Finance/Revenue Model",
  "latestVersion": 3,
  "updatedAt": "2026-01-15T00:00:00Z",
  "isArchived": false,
  "spec": {
    "name": "Revenue Model",
    "pages": [{
      "id": "p1",
      "name": "Main",
      "elements": [{
        "id": "elem1",
        "kind": "table",
        "name": "Opportunities",
        "hidden": false,
        "source": {
          "kind": "warehouse-table",
          "connectionId": "<sigma-internal-uuid>",
          "path": ["DATABASE", "SCHEMA", "OPPORTUNITIES"]
        },
        "columns": [
          { "id": "c1", "name": "Deal Amount", "formula": "[OPPORTUNITIES/Amount]", "description": "Net contract value in USD" },
          { "id": "c2", "name": "Total ARR", "formula": "Sum([OPPORTUNITIES/ARR])", "description": "Annualised recurring revenue" }
        ]
      }]
    }]
  }
}

source.kind discriminates:

  • warehouse-table — element maps directly to a warehouse table. Has connectionId and path (array of path segments forming the fully-qualified table name). project() writes an SL source when connectionMappings covers this connectionId.
  • table — element is a derived view layered on top of another element; identified by source.elementId. No warehouse path. Wiki-only.

workbooks/<id>.json — one per workbook, in rawFiles for workbook units (summary only; no spec endpoint exists):

{
  "sigmaId": "wb-abc",
  "name": "ARR Tracker",
  "path": "Finance/Dashboards",
  "latestVersion": 2,
  "updatedAt": "2026-01-16T00:00:00Z",
  "isArchived": false,
  "workbookUrlId": "57a96EMo3G...",
  "description": "Tracks ARR by segment and cohort for the finance team"
}

Peer files (available via peerFileIndex, not rawFiles):

sigma-manifest.json — fetch summary; use for provenance only.

sigma-projection-config.json — written by fetch(), contains two fields the skill must read:

  • connectionMappings: {sigmaInternalUuid: ktxWarehouseConnectionId}. Use the mapped warehouse connection ID for entity_details when verifying warehouse identifiers found in data model specs.
  • workbookFilter: the filter settings that were active when workbooks were last fetched:
    • includeArchived (default false) — when false, archived workbooks are not in workbooks/; isArchived: true files will only appear when this was true.
    • includeExplorations (default false) — when false, exploration-type workbooks (unsaved analyses) are excluded; treat present workbooks as intentional, curated reports.
    • updatedSince (optional ISO 8601 string) — when set, only workbooks updated on or after this date are staged; the set is a recent-changes slice, not the full workspace. Do not infer that absent workbooks were deleted.

sigma-manifest.json also reflects any active dataModelFilter. When dataModelFilter.updatedSince was set during fetch, dataModelCount reflects only matching models, not the full workspace. Do not infer that absent data models were deleted.

Read sigma-projection-config.json first and keep workbookFilter in scope while processing the WorkUnit.

Required workflow

  1. Read every rawFiles entry for the WorkUnit.
  2. Read sigma-projection-config.json from the staged dir to get connectionMappings.
  3. For each data model file: extract business semantics from element names, column descriptions, and the domain context of the model. Skip hidden elements and hidden columns.
  4. For each workbook file: extract business domain knowledge from the name and description. When workbookFilter.updatedSince is set, treat the staged set as a recent-changes slice — absent workbooks were not deleted, they were simply outside the filter window.
  5. Use discover_data before writing to find existing wiki pages on the same topic.
  6. Write wiki candidates with context_candidate_write. Do not call wiki_write directly from a Sigma WorkUnit; Stage 4 reconciliation promotes candidates.
  7. Do not write or edit SL sources. The project() step owns all SL output for Sigma.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues. Use the warehouse connectionId from connectionMappings in sigma-projection-config.json, not the Sigma connection ID. If connectionMappings has no entry for the element's source.connectionId, skip entity_details — there is no mapped warehouse to verify against — and wrap any identifier references with [unverified - from <rawPath>].
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Data model elements

Warehouse-table elements (source.kind === "warehouse-table")

project() writes an SL source for a warehouse-table element only when the element's source.connectionId has an entry in connectionMappings. When no mapping exists, no SL source is written and the element is wiki-only.

To determine whether an SL source exists: check whether connectionMappings[element.source.connectionId] resolves. If it does, use sl_discover to find the source by its slugified name (<dataModelName>_<elementName>), then:

  • Read the existing SL source with sl_read_source to understand what columns and measures are captured.
  • Write a wiki candidate about the business domain if the element name, column descriptions, or data model description reveals durable knowledge not already in the wiki.
  • sl_refs in the wiki candidate should point to the already-written SL source name.

If connectionMappings has no entry for the element's source.connectionId, treat the element as wiki-only — do not attempt sl_discover or sl_read_source for it, as no source was written.

Joins within a data model

Joins are not projected in v1; joins: [] is always written by project(). Lookup() formulas may be described in wiki prose instead.

Non-warehouse elements (source.kind === "table")

These reference another element by elementId — they are derived views layered on top of a warehouse-table element. They have no warehouse path of their own. Do not attempt SL writes for these elements. They may produce wiki candidates if their column names or descriptions reveal business semantics not captured by the underlying warehouse-table element.

Workbooks

Workbooks have summary metadata only. There is no spec endpoint.

Extract business domain knowledge from:

  • name: the workbook's primary topic (e.g. "ARR Tracker" → ARR tracking concepts)
  • description: business context and intended audience
  • path: team or functional area (e.g. Finance/Dashboards)

Write wiki candidates when the name or description reveals a reusable business concept, metric definition, or domain convention. Write one candidate per distinct concept, not one per workbook.

Skip workbooks whose name or description contains no durable business semantics (e.g. "Untitled Workbook", "Test Dashboard").

Capture rules

Write wiki candidates for:

  • Metric definitions mentioned in element names or column descriptions (e.g. "Net ARR", "Churned MRR")
  • Domain conventions such as cohort definitions, segment taxonomies, or fiscal calendar rules
  • Relationships between business entities revealed by data model joins

Skip:

  • Visualization settings, layout, colors, chart types
  • Owner names, folder paths, and version numbers as wiki narrative
  • Hidden elements and hidden columns
  • Data model names that are purely technical with no business meaning
  • When workbookFilter.includeExplorations is false (the default), all staged workbooks are intentional reports — no extra exploration filter needed. When it is true, workbooks without a description or with a generic auto-generated name are likely ephemeral explorations; skip those.

Usage signals

Sigma workbooks carry latestVersion but no usage counts. Treat a higher latestVersion as weak evidence of continued maintenance; do not include version numbers in wiki prose.

指导如何将可复用的业务指标、派生视图或连接模式捕获至语义层。涵盖判断标准、SQL方言适配及泛化原则,旨在避免重复发现,保持语义层简洁通用。
产生可复用的业务指标或计算逻辑 发现新的多表连接模式 需要创建通用的分析维度
packages/cli/src/skills/sl_capture/SKILL.md
npx skills add Kaelio/ktx --skill sl_capture -g -y
SKILL.md
Frontmatter
{
    "name": "sl_capture",
    "callers": [
        "memory_agent"
    ],
    "description": "How to capture new reusable patterns into ktx's semantic layer - when a measure, segment, or join belongs in the catalog and how to write it generically so it stays small and useful over time. Loaded by the post-turn memory-agent only. The research agent does not write to the SL."
}

Semantic Layer - Capture

This skill covers when and how to capture new patterns into the semantic layer. For schema reference and query grammar, load the sl skill first.

When the current turn produces a reusable pattern (business metric, derived view, join pattern, computed dimension), capture it so future queries can reach for it instead of rediscovering it.

SQL dialect

The user-facing prompt includes a Warehouse: line under the SL Sources index (e.g. Warehouse: BIGQUERY). All expr strings - measure expressions, segment predicates, computed-column SQL - execute on that warehouse and must use its syntax. Date arithmetic in particular varies by dialect:

  • BigQuery: transaction_date >= TIMESTAMP_SUB(CURRENT_TIMESTAMP(), INTERVAL 90 DAY) (when the column is TIMESTAMP); event_date >= DATE_SUB(CURRENT_DATE(), INTERVAL 90 DAY) (when DATE).
  • Postgres / Redshift: transaction_date >= current_date - interval '90 days'.
  • Snowflake: transaction_date >= dateadd(day, -90, current_timestamp()).

Match the column's manifest type (type: time → TIMESTAMP/DATETIME on the warehouse) - comparing TIMESTAMP to a DATE-arithmetic result fails on BigQuery. After every sl_edit_source/sl_write_source, the inline validator runs a LIMIT 1 warehouse probe per measure and surfaces dialect mismatches; if you see an error trailer, fix the expression and retry rather than leaving the source for the post-squash gate to revert.

What's worth capturing

  • Business metric aggregations (ARR, MRR, revenue, churn, retention, conversion, LTV, CAC).
  • Derived calculations combining multiple signals (risk scores, health scores, composite KPIs).
  • Multi-table join patterns producing a reusable analytical view.
  • Computed categories or flags useful as reusable dimensions (case when num_protocols >= 3 then 'power' else 'regular' end).
  • Missing joins between two sources that both exist but aren't connected in the join graph.

Skip:

  • Simple SELECT * LIMIT 10 previews.
  • Trivial COUNT(*) on one table with no business filtering.
  • One-off ad-hoc explorations unlikely to repeat.
  • Equivalent measures that already exist (cite the existing one as source.measure_name).

When in doubt, capture. Measures are easy to remove but impossible to recover from a lost conversation.

Generalization rules

The SL must stay small and general over time. Before adding a measure, decide whether it belongs as a generic pattern or a specific constant.

Prefer one generic measure with query-time filters over N hardcoded variants.

Anti-pattern:

- name: revenue_us_region
  expr: sum(case when region = 'US' then amount end)
- name: revenue_eu_region
  expr: sum(case when region = 'EU' then amount end)

Preferred:

- name: total_revenue
  expr: sum(amount)

Callers filter region = 'US' at query time.

Bake constants in only when the filter has named business meaning that won't change (enterprise_arr for a contractually defined tier), cannot be expressed via the source's dimensions, or comes from a regulated/fixed list.

Time anchors and value lists belong in callers' filters, not in measure expressions or source SQL.

  • Anti-pattern (date anchor inlined): expr: count(distinct case when transaction_date >= '2026-04-12' then customer_id end) - the date will need editing every time the question shifts, and every reader has to discover it.
  • Anti-pattern (value list inlined in source SQL): WHERE product_category_1 IN ('Testosterone', 'Weight Loss', …) - locks the source to today's catalog and blocks callers from broadening or narrowing.
  • Preferred: a generic measure (count(distinct customer_id)) plus either a named segment that captures the meaning of the anchor (gh_new_products_since_launch) or a query-time filter. Callers compose; the source stays small.
  • A date is durable to bake in only when it represents a regulatory cutover, a contractually fixed boundary, or a one-time event that reshapes how the source itself is read.

If you create a segment whose expr matches a measure's filter, the measure MUST reference the segment via segments: [segment_name] rather than re-inlining the predicate. This is the canonical pattern even with a single measure - duplicating the predicate inline defeats the purpose of naming it.

Anti-pattern:

segments:
  - name: engaged_subscriber
    expr: "is_paid = true AND <date-window-90-days-on-transaction_date>"
measures:
  - name: engaged_subscriber_count
    expr: "count(distinct case when is_paid = true and transaction_date >= current_date - interval '90 day' then admin_user_id end)"

Preferred:

segments:
  - name: engaged_subscriber
    expr: "is_paid = true AND <date-window-90-days-on-transaction_date>"
measures:
  - name: engaged_subscriber_count
    expr: "count(distinct admin_user_id)"
    segments: [engaged_subscriber]

Use computed dimensions for derived categories. A flag like is_power_user belongs on columns[] with expr, not inlined into every measure.

Extract repeated filter bundles into named segments. If the same predicate appears on multiple measures of the same source, lift it to a segments[] entry and have each measure reference it. One edit updates every measure that depends on it.

Never write a standalone file on a manifest-backed name. If sl_discover({ query: "<table-or-source-name>" }) finds an existing schema for that name, you MUST write an overlay. A standalone with sql: or table: on a manifest-backed name clobbers the inherited columns and joins; sl_write_source and sl_validate both reject this shape with a clear fix hint. Always run sl_discover before your first write on any existing name.

Overlay before/after examples:

# Wrong: patches an inherited manifest column through columns:
name: fct_orders
columns:
  - name: status
    descriptions:
      user: "Order lifecycle status."
# Right: patch inherited columns with column_overrides:
name: fct_orders
column_overrides:
  - name: status
    descriptions:
      user: "Order lifecycle status."
columns:
  - name: is_large_order
    type: boolean
    expr: "amount > 1000"

Overlay YAML may include measures:, segments:, descriptions:, joins:, disable_joins:, exclude_columns:, column_overrides:, and computed-only columns: entries with expr and type. Do not include sql:, table:, grain:, or base-table columns:.

Prefer overlay decomposition over standalone SQL sources. Before reaching for source_type: sql, check whether the metric decomposes into measures on existing overlays (including cross-source derived measures). Use source_type: sql only when:

  • The metric requires per-user/per-entity derivation that cannot be expressed as a single expr (e.g., EXISTS over a time-windowed subset), OR
  • The metric requires multi-step CTEs whose intermediate grain is not a column in any existing source.

When an sql source is unavoidable, note in its descriptions map which SL gap forced the choice so it can be retired once the primitive ships. It must target a name NOT in the manifest - pick a distinct one (e.g. mrr_waterfall_rollup, not fct_orders).

Slim standalone sources via inherits_columns_from

When a standalone SQL source filters or projects from a single manifest-backed base table (the common pattern for derived views like aav_consignments over MARTS.CONSIGNMENTS), set inherits_columns_from: to the base table's manifest key and list only column names in columns:. Compose-time enrichment fills type, descriptions, and role from the matching manifest column.

Discover the manifest key with sl_discover - pass the bare name (CONSIGNMENTS), the fully-qualified path (ANALYTICS.MARTS.CONSIGNMENTS), or any suffix; the tool resolves all forms and prints the canonical key in its output.

name: aav_consignments
descriptions:
  user: AAV consignments - filtered view of MARTS.CONSIGNMENTS for the auto-auction-vaulting channel.
source_type: sql
sql: |
  SELECT CONSIGNED_ITEM_ID, CASH_ADV_AMOUNT, ALT_VALUE_COMBINED, my_derived_flag
  FROM MARTS.CONSIGNMENTS
  WHERE IS_AUTO_AUCTION_VAULTING_SUBMISSION = TRUE
    AND IS_CARD_SHOW_SUBMISSION = FALSE
    AND CONSIGNMENT_CANCELED_FLAG = FALSE
inherits_columns_from: CONSIGNMENTS
grain: [CONSIGNED_ITEM_ID]
columns:
  - { name: CONSIGNED_ITEM_ID }      # type/descriptions inherited from manifest
  - { name: CASH_ADV_AMOUNT }
  - { name: ALT_VALUE_COMBINED }
  - { name: my_derived_flag, type: boolean, expr: "CASH_ADV_AMOUNT > 0", descriptions: { user: "Computed locally - has any cash advance." } }
measures:
  - name: total_cash_advance
    expr: sum(CASH_ADV_AMOUNT)

Rules:

  • Inheritance fills only blank fields. If you set a description locally, it wins - useful when the base description is misleading in the filtered view.
  • A column not in the manifest (a derived/aliased column, or one from a different table in a JOIN) needs its own type and description declared.
  • If inherits_columns_from doesn't resolve, the source still loads, but every column without a type triggers a validator error on the warehouse probe - sl_discover first to confirm the key.
  • Don't use inherits_columns_from for sources backed by table: (those should be overlays - see the rule against shadowing the manifest above).

Refinement - replace, don't append

When the user corrects a prior answer, the existing measure is wrong by the user's own standard. Replace it, don't add a parallel measure.

Signals that the current turn is a refinement:

  • "no, I meant...", "actually use X", "exclude Y", "wait, by X I mean Z".
  • Pushback on a prior result ("that's wrong because...", "this should be higher").
  • Redefinition of a term used in an existing measure.

Distinguishing question: would the prior measure still be correct for someone else asking the prior question? If no → replace. If yes → add.

Edit SL vs document in wiki

If the user explicitly names an SL artifact and asks to change it, the primary action is always an SL tool call. Examples:

  • "edit the source", "edit the YAML", "edit fct_intakes.yaml" → sl_edit_source or sl_write_source.
  • "refine the measure", "change the filter on active_users", "fix the expr", "add is_test = false" → sl_edit_source on the source that owns the measure.
  • "don't create a new one, update the existing" → sl_edit_source (never sl_write_source with a new source name; never wiki_write as the only action).

A wiki update may ALSO make sense in the same turn (owner note, lineage, caveat), but it is never a substitute for editing the YAML when the user's request is about changing the measure/source definition itself.

Wiki-only is correct when the user is documenting about the measure (definition in business terms, owner, policy, glossary, examples of when to use it) without changing its SQL expression or filters.

Before sl_write_source, call entity_details on the target table to confirm column names and types match the YAML being written.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Tool sequence

  1. sl_discover - see what source files exist.
  2. sl_discover({ query: "<table-or-source-name>" }) - REQUIRED before the first write on any name. Shows columns/joins/grain from the manifest. If the call returns a schema, you MUST write an overlay, not a standalone. Skipping this is the #1 cause of accidentally shadowing the manifest.
  3. sl_read_source({ connectionId, sourceName }) - read the raw YAML before editing.
  4. For modifications: sl_edit_source({ connectionId, sourceName, yaml_edits: [{ oldText, newText, reason }] }) with exact-string replacements. oldText must match exactly and be unique in the file.
  5. For new sources or full rewrites: sl_write_source({ connectionId, sourceName, source }) with the full structured source definition.
  6. For join discovery: use sql_execution({connectionId: "warehouse", sql: "SELECT count(*) FROM public.orders o JOIN public.customers c ON c.id = o.customer_id LIMIT 20"}) with the target warehouse connection id and dialect-correct table names to verify the join key exists in both tables and assess cardinality before declaring the join.
  7. Cross-reference knowledge: author the edge once on the wiki side via sl_refs: [source_name] in the page's front-matter. The reverse edge (wiki pages that cite an SL source) is derived automatically by the reconciler - do not add a knowledge_refs: field to SL YAMLs.
  8. sl_validate - run after writing or editing to surface schema issues, duplicate measure names, and cross-source validation errors. Read-only; the writes are already committed (the squash-at-end flow will collapse them into one commit).

Editing patterns

  • sl_edit_source is the workhorse for additive changes: add a measure, add a join, tweak a description, replace a filter. Cheap, targeted, preserves the rest of the file.
  • sl_write_source is for brand-new sources or when the entire file needs restructuring. It overwrites the file completely.
  • Do NOT modify existing measures or their descriptions unless the current turn explicitly corrects them.
  • During bundle/external ingest, include rawPaths on every sl_write_source/sl_edit_source call with only the raw files that directly support the SL action.

Worked example - additive overlay

Conversation:

  • User: "What was the average order value last quarter?"
  • Assistant fell back to SQL: SELECT AVG(amount) FROM orders WHERE order_date >= ...

Existing index: orders [measures=0, joins=0] - candidate for enrichment.

sl_discover()
  → orders.yaml does not exist yet
sl_discover({ query: "orders" })
  → see grain, columns, no current overlay
sl_write_source({
  connectionId: "warehouse",
  sourceName: "orders",
  source: {
    name: "orders",
    measures: [{
      name: "avg_order_value",
      expr: "avg(amount)",
      description: "Mean order transaction amount - filter by product_category at query time"
    }]
  }
})
sl_validate({ connectionId: "warehouse" })
  → clean

The overlay only contains name and measures - no columns, grain, or table. Those are inherited from the manifest.

Worked example - refinement (replace)

Prior turn:

  • [user] "How many active users do we have per region?"
  • [assistant] "… used count(*) filter: last_login_at > now() - interval '30 days'"

Current user: "Wait, by 'active' I mean users who have placed an order in the last 30 days, not just logged in."

The existing users.active_count measure is wrong by the new definition.

sl_read_source({ connectionId: "warehouse", sourceName: "users" })
  → see the wrong measure
sl_edit_source({
  connectionId: "warehouse",
  sourceName: "users",
  yaml_edits: [{
    oldText: "  - name: active_count\n    expr: \"count(*)\"\n    filter: \"last_login_at > now() - interval '30 days'\"\n    description: Users who logged in within the last 30 days",
    newText: "  - name: active_count\n    expr: \"count(distinct case when last_order_at > now() - interval '30 days' then user_id end)\"\n    description: Users with at least one order in the last 30 days"
  }]
})
sl_validate({ connectionId: "warehouse" })

If you only added a new measure, the old incorrect active_count would stay and future queries would keep answering the wrong question.

Worked example - new join

Prior turn: user asked to correlate LTV with protocol count; assistant joined fct_orders with fct_mau_multiprotocol on admin_user_id in raw SQL.

sl_read_source({ connectionId: "warehouse", sourceName: "fct_orders" })
  → no joins section yet
sql_execution({
  connectionId: "warehouse",
  sql: "SELECT COUNT(*), COUNT(DISTINCT a.admin_user_id) FROM public.fct_orders a JOIN public.fct_mau_multiprotocol b ON a.admin_user_id = b.admin_user_id LIMIT 1"
})
  → confirms cardinality (many orders per MAU row = many_to_one)
sl_edit_source({
  connectionId: "warehouse",
  sourceName: "fct_orders",
  yaml_edits: [{
    oldText: "measures:",
    newText: "joins:\n  - to: fct_mau_multiprotocol\n    on: admin_user_id = fct_mau_multiprotocol.admin_user_id\n    relationship: many_to_one\nmeasures:"
  }]
})
sl_validate({ connectionId: "warehouse" })

Always verify joins with sql_execution before adding them.

Rules recap

  • Read existing sources before editing (sl_read_source or sl_discover).
  • Prefer overlays over standalone sources on manifest-backed tables.
  • Prefer generic measures + query-time filters over per-value variants.
  • Time anchors and value lists belong in callers' filters, not in measure expressions.
  • A measure whose filter matches a segment MUST reference the segment via segments: [name].
  • Extract repeated predicates into named segments.
  • Use computed dimensions for derived categories.
  • When the user corrects a prior answer, replace - don't append.
  • Always run sl_validate after writing to surface issues.
  • If nothing is worth capturing, respond without calling any SL write tool.
ktx语义层技能,提供结构化目录查询。用于通过sl_query获取预定义指标(如ARR、留存率)或读取SL源YAML理解数据模型。仅支持读取和查询,不包含捕获功能。
查询预定义业务指标 阅读语义层YAML以理解数据目录 了解SL源结构
packages/cli/src/skills/sl/SKILL.md
npx skills add Kaelio/ktx --skill sl -g -y
SKILL.md
Frontmatter
{
    "name": "sl",
    "description": "ktx's semantic layer - a structured catalog of sources (tables\/views), measures, joins, and segments expressed as YAML. Covers the schema and how to query it via `sl_query`. Use when the task involves querying pre-defined metrics (ARR, churn, retention, LTV, MAU) or reading SL source YAML to understand the catalog. Capture is handled by the `sl_capture` skill (memory-agent only)."
}

Semantic Layer

ktx's semantic layer (SL) is a structured catalog. Each source represents a table, a SQL view, or an overlay that enriches a manifest-backed table with measures, computed columns, joins, and named segments. The catalog is the single source of truth for reusable business metrics.

This skill covers two parts:

  • Part 1 - Schema reference (what an SL source looks like).
  • Part 2 - Querying via sl_query.

Capture (when and how to add new patterns to the SL) is a separate concern handled by the memory-agent - see the sl_capture skill if you are running in capture mode. The research agent reads and queries the SL via the tools described here; it does not write to it.

For capture-time identifier verification, load sl_capture. Synthesis writer skills must verify warehouse identifiers with discover_data, entity_details, and sql_execution before emitting table or column names.


Part 1 - Schema reference

An SL source is a YAML file under semantic-layer/<connectionId>/. The file's name: field is the source's identity — it mirrors the warehouse identifier verbatim (e.g. Snowflake's uppercase SIGNED_UP); the filename is only a derived label. Always address sources by name through the sl_* tools, never by file path. There are three flavors:

Overlay sources

Enrich a manifest-backed table with measures, computed columns, joins, and segments. No table or sql field. The base table's columns and grain are inherited from the manifest.

name: fct_orders           # must match an existing manifest table
descriptions:
  user: "Overlay adding business measures to the orders fact table."
measures:
  - name: total_revenue
    expr: sum(amount)
    description: Total order revenue - filter by status or region at query time
columns:                    # computed dimensions only
  - name: is_large_order
    type: boolean
    expr: "amount > 1000"
column_overrides:           # metadata patches for inherited columns
  - name: status
    descriptions:
      user: "Order lifecycle status."
segments:
  - name: paid_non_refunded
    expr: "is_paid = true AND is_refunded = false"
joins:
  - to: customers
    on: "customer_id = customers.id"
    relationship: many_to_one

Rules:

  • Do not repeat base-table columns, grain, table, or source_type in an overlay - those are inherited.
  • Overlay columns MUST be computed (expr + type).
  • Use column_overrides to add descriptions or metadata to inherited manifest columns. Do not put type or expr in column_overrides.
  • exclude_columns hides specific manifest columns; disable_joins suppresses specific auto-detected joins.

Standalone table sources

Self-contained; own their schema. Has source_type: table and table:.

name: account_health_scores
source_type: table
table: "analytics.account_health_scores"
grain: [account_id, snapshot_date]
columns:
  - name: account_id
    type: string
  - name: snapshot_date
    type: time
    role: time
  - name: health_score
    type: number
measures:
  - name: avg_health_score
    expr: avg(health_score)

Standalone SQL sources

Self-contained; schema derived from a SQL query. Has source_type: sql and sql:.

name: monthly_cancellations
source_type: sql
sql: |
  SELECT
    date_trunc('month', cancelled_at) AS month,
    customer_id,
    plan_name,
    mrr_amount
  FROM subscriptions
  WHERE status = 'cancelled'
grain: [customer_id, month]
columns:
  - name: month
    type: time
    role: time
  - name: customer_id
    type: string
  - name: plan_name
    type: string
  - name: mrr_amount
    type: number
measures:
  - name: cancellation_count
    expr: count(*)

An SQL source is a one-shot answer: the aggregation is frozen, callers cannot re-group or re-filter by columns the SQL has collapsed, and the source is disconnected from the join graph. Prefer overlays + measures over SQL sources when possible - the sl_capture skill covers when SQL is justified.

Columns

Every standalone column requires name and type. Overlays have computed columns in columns: and manifest column metadata patches in column_overrides:.

  • type: one of string, number, boolean, time. Map LookML date/datetime/timestamptime. Map LookML yesnoboolean.
  • role (optional): time enables time-granularity queries (month, week, day). default is the implicit fallback.
  • visibility (optional): public, internal, or hidden.
  • expr (optional for standalone, required for overlay columns): SQL expression that computes the value. Expanded by sqlglot before generating SQL, so you can reference other columns on the same source.

Grain

grain: [col_a, col_b] - the set of columns that uniquely identify one row. The query engine uses grain to prevent fanout in joins. Overlays inherit grain from the manifest unless they override.

Joins

joins:
  - to: customers                                    # target source name
    on: "customer_id = customers.id"                 # local_col = TARGET.target_col
    relationship: many_to_one                        # or one_to_many, one_to_one
    alias: primary_customer                          # optional - lets you join the same target twice
  • on format: local_col = TARGET.target_col. Always qualify the right side with the target source name.
  • relationship is the cardinality from this source to the target. Most joins are many_to_one (FK → PK on the parent).

Measures

measures:
  - name: total_arr
    expr: sum(arr_amount)
    description: Sum of ARR - filter by plan_name at query time
    filter: "is_active = true"
    segments: [paid_non_refunded]
  • name (required, snake_case).
  • expr (required): any valid SQL aggregate - sum(x), count(*), count(distinct user_id), avg(score).
  • description (required on capture): what the measure computes and how to use it.
  • filter (optional): SQL predicate applied as a WHERE clause specific to this measure.
  • segments (optional): names of segments defined on the same source. The engine AND-composes each segment's expr into this measure's effective filter.

Use safe_divide(num, den) for ratio measures to avoid division by zero.

Segments

segments:
  - name: paid_non_refunded
    expr: "is_paid = true AND is_refunded = false"
    description: Orders that were paid and not refunded

Named, reusable boolean predicates scoped to one source. Reference by bare name in a measure's segments: [], or by dotted form source.segment_name in an sl_query. Segments are predicates only - they are NOT selectable as dimensions. If you need to group by the predicate, add a columns[] entry instead.

Cross-references with the wiki

The reverse edge (wiki pages that cite this source) is derived automatically from each wiki's sl_refs: - you don't emit anything on the SL side. Author the edge once on the wiki via sl_refs:; the post-write reconciler populates the knowledge↔SL index.


Part 2 - Querying via sl_query

The sl_query tool generates correct SQL from a structured query. It handles joins, fanout prevention, aggregation correctness, and filter classification automatically. Prefer it over writing raw SQL whenever the SL has the relevant sources.

When to prefer sl_query over raw SQL

  • A pre-defined measure already exists (source.measure_name appears in the catalog).
  • The question combines fields from multiple sources - the engine resolves the join path automatically.
  • The question asks for a standard metric (revenue, ARR, churn, retention, LTV, conversion, MAU, etc.) - even if no pre-defined measure exists, a runtime aggregation over a catalog column is usually correct.

Use raw SQL (sql_execution) only when:

  • The computation requires multi-step CTEs whose intermediate grain is not a column in any source.
  • The question explicitly asks for a one-off exploration that will never be asked again.

Input shape

{
  "connectionId": "uuid-of-the-connection",
  "measures": ["orders.total_revenue", "sum(orders.amount)"],
  "dimensions": ["customers.segment", { "field": "orders.created_at", "granularity": "month" }],
  "filters": ["orders.status != 'cancelled'", "orders.total_revenue > 10000"],
  "segments": ["orders.paid_non_refunded"],
  "order_by": [{ "field": "orders.created_at", "direction": "desc" }],
  "limit": 1000
}
  • measures: mix pre-defined refs (source.measure) and runtime aggregations (sum(source.column)).
  • dimensions: column refs or { field, granularity } objects for time grains (day, week, month, quarter, year).
  • filters: free-form SQL predicates. The engine auto-classifies each as WHERE or HAVING based on whether it references an aggregated measure.
  • segments: dotted source.segment_name. Each segment is AND-ed into the effective filter of every measure whose base source matches. Segments never become a global WHERE - use filters for cross-source predicates.
  • order_by: string or { field, direction }. Direction defaults to asc.
  • limit: integer row cap.

Join resolution

You don't specify a base table. The engine infers the set of sources needed from the fields you reference and resolves the shortest join path through the catalog's declared joins. If no path exists between two sources, the query fails with a path-not-found error - check discover_data or sl_discover to see which sources are connected.

Worked examples

Cross-source query - engine resolves account_health_scores → accounts ← opportunities automatically:

{
  "measures": ["account_health_scores.avg_health_score"],
  "dimensions": ["opportunities.stage"],
  "filters": ["opportunities.stage != 'Closed Won'"]
}

Monthly ARR trend with a segment:

{
  "measures": ["subscriptions.arr"],
  "dimensions": [{ "field": "subscriptions.month", "granularity": "month" }],
  "segments": ["subscriptions.paid_non_refunded"],
  "order_by": [{ "field": "subscriptions.month", "direction": "asc" }]
}

Multi-source with runtime aggregation:

{
  "measures": ["sum(orders.amount)", "count(support_tickets.ticket_id)"],
  "dimensions": ["customers.segment"]
}
用于将用户偏好、指标定义及业务规则等持久化知识写入知识库。通过识别可复用信息,建立跨页面引用并关联语义层,避免存储过时数据,确保知识的长期准确性与一致性。
发现需持久化的业务规则或指标定义 提取跨系统映射或组织约定
packages/cli/src/skills/wiki_capture/SKILL.md
npx skills add Kaelio/ktx --skill wiki_capture -g -y
SKILL.md
Frontmatter
{
    "name": "wiki_capture",
    "callers": [
        "memory_agent"
    ],
    "description": "ktx's knowledge base - wiki pages for durable, reusable business knowledge. Covers capture workflow for user preferences, metric definitions, organizational conventions, and cross-references between wiki pages and semantic-layer sources. Loaded by the post-turn memory-agent only. The research agent reads wiki via `wiki_read`\/`wiki_search` but does not write it."
}

Wiki Capture

Role

The knowledge base stores durable, reusable business knowledge for an analytics assistant. Each page is a self-contained rule, definition, or convention that answers "how should this concept be handled in this organization?" - written once and reused across chats.

Scope selection is handled by the runtime:

  • When user-scoped knowledge is enabled AND the caller is a chat turn, writes go to the user's personal scope.
  • When the caller is an admin-driven ingest (sourceType: 'external_ingest'), writes go to the global scope.
  • When user-scoped knowledge is disabled, all writes go to the global scope.

The wiki_write tool picks the right scope based on the session. Capture logic does not need to choose - focus on whether the content is worth capturing at all.

What to capture

Capture when the user or the ingested document expresses:

  • A metric definition ("revenue means booked revenue after refunds").
  • A filter or convention that should always apply ("exclude test accounts when reporting ARR").
  • A mapping or alias ("mood_stress_sleep = Oxytocin protocol").
  • A domain rule that is not visible from column names alone ("status = 'T' means terminated, not 'terminated'").
  • A link or external system convention ("medplum_patient_id is the primary key in the EMR at https://emr.example/patients/{id}").

Do NOT capture:

  • One-off requests ("answer under 100 words").
  • Temporary instructions scoped to the current chat.
  • Ad-hoc formatting preferences.
  • Information already present in the semantic layer (column names, join paths, measure formulas - those belong in SL).
  • Query results, snapshots, or time-bounded benchmark tables. Numbers go stale; pasting "Oct 2025: 25%, Nov 2025: 19.9%, …" creates misinformation as soon as new data lands. Reference the SL source by name (sl_refs) and let future query tools pull live data - the wiki captures the rule (definition, exclusion, segmentation), the SL source captures the measure, and query execution captures the current values.
  • Interpretive narrative tied to a specific snapshot ("M1 retention degraded sharply from Dec 2025"). The observation is anchored to data that will move; the actionable convention (e.g., "always exclude in-progress cohorts") may be worth capturing on its own, but the snapshot-specific commentary is not.

If nothing is worth capturing, respond without calling any tool.

Workflow

  1. Read the wiki index (provided in the prompt) and decide whether the turn introduces durable knowledge.
  2. Before writing, search for related content so cross-references are accurate:
    • discover_data first when a page relates to data or SL concepts - find existing wiki pages, SL sources, and raw warehouse schema together.
    • wiki_search with the topic - find related wiki pages to populate refs.
    • sl_discover with the concept - if the page defines a metric (revenue, churn, retention, LTV, ARR, MRR, CAC, attribution, etc.), find matching SL sources or measures to populate sl_refs. If no matches, pass sl_refs: [] so future readers know you checked.
  3. If updating an existing page, wiki_read it first. Use the returned structured.content or markdown body as the exact stored text for targeted replacements; current tags, refs, and sl_refs are returned in structured metadata.
  4. wiki_write to create or update. Prefer merging into an existing page over creating a new one.
  5. wiki_remove only when a page is truly obsolete - not to replace stale content (update it instead).

For bundle/external ingest, include rawPaths on every wiki_write/wiki_remove call with only the raw files that directly support that wiki action. This keeps ingest provenance tied to the actual source file, not every file in the WorkUnit.

Identifier Verification Protocol

Before writing a wiki page or SL source on any topic:

  1. discover_data({query: "<topic>"}) - see what wikis, SL sources, and raw tables already exist. Prefer updating existing pages over creating new ones.

Before emitting any schema.table or schema.table.column into a wiki body, SL source, tables: frontmatter, sl_refs, or emit_unmapped_fallback:

  1. entity_details({connectionId, targets: [{display: "<identifier>"}]}) - confirm the identifier resolves; inspect native types, FK/PK, and sampleValues.
  2. For literal values from the source, such as status codes or plan tiers, check whether they appear in entity_details sampleValues for the relevant column. If sampleValues is short or the sample may have missed real values, run a sql_execution probe with the same warehouse connection id: sql_execution({connectionId, sql: "SELECT DISTINCT <col> FROM <ref> LIMIT 50"}).
  3. If the candidate identifier still does not resolve, do one of:
    • Use sql_execution({connectionId, sql: "SELECT 1 FROM <ref> LIMIT 0"}). If it errors, the identifier is fictional.
    • Wrap the identifier in [unverified - from <rawPath>] in the wiki body, citing the exact raw path that mentioned it.
    • When recording emit_unmapped_fallback with no_physical_table, include the failing probe error in clarification.
  4. Never copy <schema>.<table> placeholder strings from these instructions into output.

Keys, summaries, and content

  • Keys are short kebab-case topic identifiers: leads-source-filter, revenue-definition, churn-calculation. No namespacing, no prefixes.
  • Summary is a one-line hook (≤200 chars) shown in the index.
  • Content is concise markdown - actionable rules, not prose.
## [Topic Title]
- Rule or preference statement
- Another rule if applicable

Prefer fewer, richer pages over many thin ones. Each page covers one coherent topic thoroughly. If the new information relates to an existing page, update that page instead of fragmenting the knowledge.

Tags, refs, sl_refs

The wiki_write tool accepts three array fields that go into the page frontmatter:

  • tags: 1–3 short lowercase topic tags (["finance"], ["data-quality"]). Call wiki_list_tags first to reuse existing tags for consistency.
  • refs: keys of related wiki pages. Add when the new page materially depends on concepts from another (e.g., a churn definition that uses the paid-orders filter from a revenue definition). Don't add refs just because pages share a topic area.
  • sl_refs: names of SL sources or measures the page relates to. Format: "source_name" or "source_name.measure_name". Discover via sl_discover → inspect with sl_read_source → include the confirmed matches.

Wiki page keys must be flat slugs. Use large-contract-requesters, not historic-sql/large-contract-requesters. Use tags, source, and content headings for grouping.

Replace semantics

All three fields use REPLACE semantics on update:

  • Omit the field → existing value is kept.
  • Pass [] → field is cleared.
  • Pass [values] → replaces existing with exactly those values (no merging).

Connection scoping

A project may have several databases whose schemas reuse the same concept names (two warehouses each with orders, customers, …). The connections frontmatter field keeps database-specific pages from polluting searches about other databases.

  • The wiki_write tool accepts a connections field (list of connection ids, same REPLACE semantics as tags). Absent or empty ⇒ the page is unscoped and applies to every connection.
  • When this ingest/turn is scoped to a connection (its id appears in the prompt context — e.g. connectionId: warehouse in the SL Sources header or the <context> block), set connections: [<that id>] on pages whose content is specific to that database ("in this warehouse user_id is the device id, not the account id"). Pair this with a connection-distinctive key so two databases' same-concept pages can coexist: orders_sales_db, not orders.
  • Leave connections empty for clearly org-wide knowledge ("fiscal year starts in February") so it stays visible everywhere. Do not scope a page to a connection just because the turn happened to be connection-scoped.
  • Keys are still a flat, global namespace; connections does not namespace them. A connection-scoped write whose key already belongs to a page scoped to a different connection is rejected to prevent silently overwriting it — pick a connection-distinctive key instead.

Editing existing pages

Two modes:

  • Full content - pass content to rewrite the whole page. Use when the page structure needs to change.
  • Targeted edits - pass replacements: [{ oldText, newText }] to apply exact-string replacements. Use for small updates; preserves the rest of the page.

When editing, read the page first so the edit matches exact whitespace and indentation.

Overriding an organization rule

Organization (GLOBAL) pages are read-only from a user's personal-scope session. To override a global rule for a single user, write a personal page with the same key. At read time the USER page wins.

Worked example - capturing a metric with cross-references

User says: "Going forward, the official refund rate is total refunded amount divided by total gross transaction amount."

wiki_list_tags()
  → existing tags include "finance"
wiki_search({ query: "refund revenue paid orders" })
  → returns `revenue-definition` (related - defines paid-orders filter)
sl_discover({ query: "refund rate" })
  → returns fct_orders (score 0.08), fct_gaap_revenue (0.06)
sl_read_source({ connectionId: "warehouse", sourceName: "fct_orders" })
  → confirms amount_refunded_dollars and transaction_amount_dollars exist
wiki_write({
  key: "refund-rate-definition",
  summary: "Refund rate = refunded amount / gross transaction amount",
  content: "## Refund Rate\n- Definition: sum(amount_refunded_dollars) / sum(transaction_amount_dollars)\n- Source of truth: fct_orders\n- Related: see revenue-definition for paid-orders filter.",
  tags: ["finance"],
  refs: ["revenue-definition"],
  sl_refs: ["fct_orders.refund_rate_pct", "fct_orders"]
})

Search-then-write order matters. Cross-references are part of the page's identity, not an afterthought.

Rules

  • Read existing pages before updating them.
  • Prefer merging into an existing page over creating a new one.
  • Prefer fewer, richer pages over many thin ones.
  • Write content as clear, actionable rules - not narrative prose.
  • Discover cross-references via search before writing, not after.
  • If nothing is worth capturing, respond without calling any tool.
自动化安装和配置 ktx 数据代理上下文层。支持非交互式设置、数据库连接、嵌入配置及 Agent 集成,适用于添加 ktx、连接数据源、导入 schema 或排查本地安装问题。
用户请求在项目中添加 ktx 需要连接数据源或配置嵌入后端 安装 agent 规则或集成 导入 schema 或执行数据摄入 排查本地 ktx 安装故障
skills/ktx/SKILL.md
npx skills add Kaelio/ktx --skill ktx -g -y
SKILL.md
Frontmatter
{
    "name": "ktx",
    "description": "Installs and configures ktx, the open-source context layer for data agents — runs ktx setup non-interactively with hidden CLI flags, configures database connections and embeddings, installs agent integration, and verifies readiness. Use when the user asks an agent to add ktx to a project, connect data sources, install agent rules, ingest schema, or troubleshoot a local ktx install."
}

ktx

Install and configure ktx, the open-source context layer for data agents. Use this skill when a user wants an agent to add ktx to a project, connect data sources, build initial context, install agent integration, or troubleshoot a local ktx setup.

Operating rules

  • Act autonomously when the user asks you to install or configure ktx. The non-interactive scripted flow below is the canonical path — bare ktx setup is interactive (clack prompts) and an agent cannot drive it.
  • Setup's non-interactive flags are intentionally hidden from --help. Use the flags listed below; verify uncommon flags against the docs at https://docs.kaelio.com/ktx/ or this skill — not against --help output.
  • Ask only for values you cannot infer: project directory, connection targets, credentials, account identifiers, and source selections.
  • Prefer file:/abs/path secret refs over env:VAR_NAME. env: refs are re-resolved against the process environment on every ktx run, so a var exported only in the setup shell is gone when ktx ingest or ktx mcp start runs later — the secret silently resolves to empty and the connection fails. file: refs read from disk and survive across shells. The same caveat applies to --*-api-key-env flags: the named var must be present in every shell that runs ktx, including the ktx mcp daemon's environment.
  • A literal database URL is safe to pass — ktx setup auto-externalizes it into .ktx/secrets/<id>-url and rewrites ktx.yaml to a file: ref (see workflow step 2). Source credential refs are not auto-externalized: write the secret to a file under .ktx/secrets/ (chmod 600) and pass a file: ref. Never ask the user to paste a secret when a file: or env: ref works.
  • Do not commit .ktx/secrets/*.
  • Print each command you run and its result.
  • Setup and ingest can run for many minutes (LLM-heavy source ingests take the longest), and from the outside a slow step looks identical to a stuck one. Don't go silent: say what's about to run and that it may take a while, then post brief progress/liveness updates while it runs (see step 4) so the user never has to wonder whether it stalled — otherwise they may kill it mid-run.
  • If a command fails, identify the cause and change something before retrying.

Gather inputs once

Before invoking ktx setup, collect in one round:

  1. Project directory (default: current working directory).
  2. LLM backend and key strategy. In --no-input mode the CLI defaults to anthropic and requires an API key. When the user is inside Claude Code, pass --llm-backend claude-code explicitly; otherwise pass --llm-backend anthropic --anthropic-api-key-env ANTHROPIC_API_KEY.
  3. Embedding backend (sentence-transformers is the local default and needs no key; use openai only if the user already has a key, then pass --embedding-api-key-env OPENAI_API_KEY).
  4. Database: driver, connection id, URL (or env: / file: ref), and one or more schemas.
  5. Optional context sources (dbt, Metabase, Looker, LookML, MetricFlow, Notion). Add each one with a follow-up ktx setup --source … run (see Add context sources); use --skip-sources only when the user has none.

Do not discover these inputs across multiple setup runs.

Install workflow

  1. Detect the install path. If the working directory contains packages/cli/dist/bin.js or pnpm-workspace.yaml referencing @kaelio/ktx you are inside the ktx monorepo — build and link the local CLI with pnpm and do not run npm install -g. Otherwise:

    node --version    # require >= 22; stop and ask the user if older
    ktx --version || npm install -g @kaelio/ktx
    
  2. Run scripted setup (canonical path):

    ktx setup --no-input --yes \
      --project-dir <path> \
      --llm-backend claude-code \
      --embedding-backend sentence-transformers \
      --database <driver> --database-connection-id <id> \
      --database-url '<raw-url | file:/abs/path>' \
      --database-schema <schema> \
      --skip-sources \
      --skip-agents
    
    • --database-schema is required for scope-bearing drivers (Postgres, MySQL, ClickHouse, SQL Server, BigQuery, Snowflake) in --no-input: setup fails fast without it unless the connection already has scope in ktx.yaml. SQLite needs no scope.
    • Configure one new database connection per setup invocation. For multiple connections, rerun setup once per connection.
    • Pasting a literal --database-url is safe: the CLI relocates the URL into .ktx/secrets/<connection-id>-url and rewrites ktx.yaml to a file: ref automatically.
    • ktx setup runs agent integration as its last step. In --no-input mode with neither --target nor --skip-agents, that step has no input, prints Run in a TTY, or pass --target <target>., and the command exits non-zero even though every database/LLM/embedding step succeeded. Pass --skip-agents to defer agents to step 5 (as above), or --target <agent> to install them inline and exit 0. Judge data-layer success from ktx status, not from this exit code.
  3. Resumability and --skip-*. Re-running ktx setup against an existing project resumes its config. Use --skip-llm, --skip-databases, --skip-sources, or --skip-embeddings to leave a slice unconfigured but let the rest complete instead of aborting on the first failure. When resuming an existing project to change one slice (e.g. only LLM), still pass the database flags from the previous run — setup validates current flags, not persisted ktx.yaml state.

  4. Build context if setup did not already complete one:

    ktx ingest <connection-id> --no-input
    

    ktx ingest always builds enriched context and requires a configured model and embeddings (set during setup); a database connection without them fails with an enrichment-readiness error. Note: ktx ingest rejects --yes together with --no-input (Choose only one runtime install mode); ktx setup accepts both. Use --no-input only for ingest.

    Ingest one connection at a time. It can run for many minutes with no stdout until it exits (LLM-heavy sources like Metabase are the slowest), so don't assume it hung, and don't pipe it through tail/head — that buffers all output to the end, so run it raw. Tell the user up front that the step is slow, then keep them posted instead of blocking silently: run the ingest in the background and poll for liveness every minute or so, reporting a one-line update each time (which connection, roughly how long it's been running, and that .ktx files are still changing) so a long run never looks stuck:

    find <path>/.ktx/worktrees <path>/.ktx/ingest-transcripts -type f -mmin -3
    

    On success, the Ingest finished summary table shows done in the Source ingest and Memory update columns with no Failed sources: section.

  5. Install agent integration:

    ktx setup --agents --target <claude-code|claude-desktop|codex|cursor|opencode|universal>
    ktx mcp start --project-dir <path>
    

    Agent integration is not usable until ktx mcp start is running. The --agents step prints this requirement as Required before using agents.

  6. Fall back to bare ktx setup only when a human is at the keyboard — it uses interactive prompts an agent cannot answer.

Add context sources

Context sources (dbt, Metabase, Looker, LookML, MetricFlow, Notion) are added one at a time--source is not repeatable, so run ktx setup once per source. Source setup is resumable against an existing project: pass --skip-databases --skip-llm --skip-embeddings --skip-agents so only the source is configured (the trailing agent step otherwise fails the run — see install step 2). Map Metabase, Looker, and LookML to an existing database connection with --source-warehouse-connection-id <db-connection-id> (required for those). dbt ignores --source-warehouse-connection-id — it maps to the warehouse by table name — so omit it for dbt. Use file:/abs/path refs for keys and tokens (see the secrets rule above); env: refs must be exported in every later ktx shell.

# dbt — pick exactly one of --source-path (local) or --source-git-url (remote).
# No --source-warehouse-connection-id: dbt maps to the warehouse by table name.
ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \
  --source dbt --source-connection-id <id> \
  --source-git-url <url> --source-branch <branch>

# Metabase
ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \
  --source metabase --source-connection-id <id> \
  --source-url <url> --source-api-key-ref file:/abs/path/metabase-api-key \
  --source-warehouse-connection-id <db-connection-id> \
  --metabase-database-id <metabase-db-id>

# Notion
ktx setup --no-input --yes --skip-databases --skip-llm --skip-embeddings --skip-agents \
  --source notion --source-connection-id <id> \
  --source-auth-token-ref file:/abs/path/notion-token \
  --notion-crawl-mode selected_roots --notion-root-page-id <page-id>

Notes:

  • --metabase-database-id is the numeric id of the warehouse inside Metabase (not the ktx connection id). Discover it from the Metabase API (GET /api/database) or UI if the user doesn't know it.
  • --notion-crawl-mode selected_roots requires at least one --notion-root-page-id (repeatable); use all_accessible to crawl everything the token can see.
  • After adding sources, ingest each new connection so its context is queryable: ktx ingest <source-connection-id> --no-input.

Files to inspect

  • ktx.yaml: project configuration.
  • .ktx/secrets/*: local secret files. Never commit them.
  • semantic-layer/<connection-id>/*.yaml: semantic sources for SQL compilation.
  • wiki/**/*.md: project context pages for agents.
  • .claude/skills/ktx/, .agents/skills/ktx/, .cursor/rules/ktx.mdc, and .opencode/commands/ktx.md: generated agent integration files.

Verification

After setup, run:

ktx connection test <connection-id>
ktx status --json --no-input
ktx sl --output plain          # lists compiled semantic sources; `ktx sl` has no --no-input

Judge readiness from ktx status --json fields, not the exit code. ktx status exits 1 whenever the LLM is none (verdict: "blocked"), even when embeddings and every database connection are healthy. Treat success as:

  • verdict: "ready" at the top of the JSON, and
  • every connections[].status === "ok" (other levels: warn, fail, skipped), and
  • every ktx connection test <id> exited 0, and
  • for each ingested source, localStats.semanticLayer[].sourceCount > 0 and localStats.wikiPages[].count > 0 — these confirm the source actually produced context. Do not rely on localStats.ingest.perConnection to confirm source ingests: it reflects only completed warehouse ingest reports and under-reports (often lists just the warehouse connection).

If the LLM is intentionally left unconfigured, verdict is blocked and the exit is non-zero by design — that is still a usable context layer, so report it as "ready, LLM optional" and judge the data layer by the connection and localStats fields above rather than retrying setup.

Troubleshooting

For known failure signatures (invalid ELF header, Native CLI binary for <plat> not found, Missing Anthropic API key, claude-code probe failure, ktx cannot work without a database on resume, Run in a TTY, or pass --target <target>. with a misleading exit 1, and a secret that resolves empty only during ktx ingest/ktx mcp), see troubleshooting.md.

Final report

End setup work with a concise report:

ktx SETUP COMPLETE

Project:     <path>
LLM:         <backend> / <model>
Embeddings:  <backend> / <model>
Connections: <name> (<driver>) status=<ok|warn|fail>
Sources:     <list or none>
Verdict:     <ready|needs action>

Next:
1. <copy-pasteable command or action>
2. <copy-pasteable command or action>

RESULT: PASS

Accueil - Wiki
Copyright © 2011-2026 iteam. Current version is 2.155.2. UTC+08:00, 2026-07-15 00:36
浙ICP备14020137号-1 $Carte des visiteurs$