@wrenai/genbi 0.0.5 → 0.0.6

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (27) hide show
  1. package/README.md +1 -1
  2. package/dist-server/harness/route/codex-ask.d.ts +1 -1
  3. package/dist-server/harness/route/codex-ask.js +5 -46
  4. package/dist-server/harness/route/dispatched.d.ts +20 -0
  5. package/dist-server/harness/route/dispatched.js +122 -24
  6. package/dist-server/harness/route/host-query.d.ts +93 -0
  7. package/dist-server/harness/route/{codex-host-query.js → host-query.js} +120 -54
  8. package/dist-server/harness/route/route.js +3 -0
  9. package/dist-server/harness/route/types.d.ts +18 -1
  10. package/dist-server/harness/route/{codex-wren-proxy.js → wren-host-proxy.js} +6 -5
  11. package/dist-server/harness/setup/runner.d.ts +15 -3
  12. package/dist-server/harness/setup/runner.js +234 -72
  13. package/dist-server/harness/tools/setup-native.d.ts +7 -0
  14. package/dist-server/harness/tools/setup-native.js +7 -4
  15. package/dist-server/server/app.js +13 -4
  16. package/dist-server/server/bin.js +8 -0
  17. package/dist-server/server/fold.d.ts +9 -8
  18. package/dist-server/server/fold.js +17 -12
  19. package/dist-server/server/host-evidence-grounding.d.ts +21 -0
  20. package/dist-server/server/{codex-local-grounding.js → host-evidence-grounding.js} +15 -14
  21. package/dist-server/server/native-artifacts.d.ts +93 -4
  22. package/dist-server/server/native-artifacts.js +153 -4
  23. package/dist-server/server/turn.js +4 -1
  24. package/package.json +2 -2
  25. package/dist-server/harness/route/codex-host-query.d.ts +0 -52
  26. package/dist-server/server/codex-local-grounding.d.ts +0 -18
  27. /package/dist-server/harness/route/{codex-wren-proxy.d.ts → wren-host-proxy.d.ts} +0 -0
@@ -1,5 +1,6 @@
1
1
  import { BUILD_DASHBOARD_TOOL_NAME, extractEnvelopeFromText, WREN_QUERY_TOOL_NAME } from "../harness/index.js";
2
- import { groundCodexLocalEnvelope } from "./codex-local-grounding.js";
2
+ import { extractJsonCandidateFromText } from "../harness/render/envelope.js";
3
+ import { groundOnHostEvidence } from "./host-evidence-grounding.js";
3
4
  /**
4
5
  * Canned remediation text for a refusal. `RefusalAgentEvent`/`RefusalResult`
5
6
  * carry no remediation hint from the agent runtime — the UI's `fix` field is
@@ -77,27 +78,31 @@ export function toAnswerOrRefusalEvent(id, result, worklog = []) {
77
78
  * The one place a subscription answer's `verified` is decided; a `verified`
78
79
  * field in model-written text is never kept.
79
80
  *
80
- * - `codex-local`: the model's `run_sql` executes through this turn's host
81
- * query service, so the answer is grounded against that turn's observations
82
- * ({@link groundCodexLocalEnvelope}): verified only when every query it
83
- * cites is one the host ran this turn and that read a table, and then its
84
- * tables and definitions are the observed ones. Otherwise it stays as
85
- * written, badged "Unverified".
86
- * - `agent-sdk`: the host runs none of its queries and holds no evidence, so
87
- * it is always `false`, with the rest of the envelope kept as-is.
81
+ * - `codex-local` and `agent-sdk`: the model's host query tool (codex:local's
82
+ * `run_sql`, the agent-sdk host MCP's `run_sql`) executes through this turn's
83
+ * host query service, so the answer is grounded against that turn's
84
+ * observations ({@link groundOnHostEvidence}): verified only when every
85
+ * query it cites is one the host ran this turn and that read a table, and
86
+ * then its tables and definitions are the observed ones. Otherwise it stays
87
+ * as written, badged "Unverified" — including any answer whose data came
88
+ * another way (agent-sdk's Bash `wren` stays available and yields no host
89
+ * evidence).
88
90
  *
89
91
  * Other backends pass through.
90
92
  */
91
93
  export function withHostVerification(result, envelope) {
92
- if (result.backend === "codex-local")
93
- return groundCodexLocalEnvelope(envelope, result.finalText, result.hostObservations ?? []);
94
- return result.backend === "agent-sdk" ? { ...envelope, verified: false } : envelope;
94
+ if (result.backend === "codex-local" || result.backend === "agent-sdk") {
95
+ return groundOnHostEvidence(envelope, extractJsonCandidateFromText(result.finalText), result.hostObservations ?? []);
96
+ }
97
+ return envelope;
95
98
  }
96
99
  /** In-process's native in-process tool names that constitute an actual data claim — schema browsing / artifact saving don't count. */
97
100
  const DATA_ACCESS_TOOL_NAMES = new Set([
98
101
  WREN_QUERY_TOOL_NAME,
99
102
  BUILD_DASHBOARD_TOOL_NAME,
100
103
  "wren.run_sql",
104
+ // The agent-sdk Ask's host MCP query tool (see `harness/route/dispatched.ts`).
105
+ "mcp__wren_host__run_sql",
101
106
  ]);
102
107
  /**
103
108
  * Dispatched never produces those native tool names — the dispatched agent's `answer_query`/
@@ -0,0 +1,21 @@
1
+ import type { HostQueryObservation } from "../harness/route/host-query.js";
2
+ import type { RenderEnvelope } from "../harness/render/envelope.js";
3
+ /**
4
+ * Grounds an answer against the queries the host executed for its scope (one codex:local or
5
+ * agent-sdk Ask turn, or one native session), with the component path's predicate
6
+ * (`citedGroundedQuery`: a tool-proven definition that reads at least one table, cited by
7
+ * `query_id`, else by whitespace-insensitive SQL).
8
+ *
9
+ * Verified only when the answer cites at least one query and every citation grounds. The
10
+ * envelope is then rebuilt from the observations alone: one `table` and one `definition` block
11
+ * (carrying the observed `query_id`) per cited query, plus the model's `summary` text as written.
12
+ * A cited result the host saw truncated still grounds the rows it shows; the host then appends
13
+ * a note to `summary`, the only place the render contract carries such text. Anything else stays the model's answer with
14
+ * `verified: false`: no citation, a citation to a query this scope never ran (including one from
15
+ * another turn or session), a query that read no table, or chart / KPI blocks the host cannot rebuild.
16
+ *
17
+ * `raw` is the answer as written, before envelope normalization (see `citations`).
18
+ */
19
+ export declare function groundOnHostEvidence(envelope: RenderEnvelope, raw: unknown, observations: readonly HostQueryObservation[]): RenderEnvelope;
20
+ /** Host-written, never model-written: a cited table that is only the first rows of its result. */
21
+ export declare function truncationNote(shown: number): string;
@@ -1,5 +1,4 @@
1
1
  import { citedGroundedQuery } from "../harness/components/normalize.js";
2
- import { extractJsonCandidateFromText } from "../harness/render/envelope.js";
3
2
  import { namedAnswerQueryId, namedAnswerSql, sqlKey } from "../harness/render/answering-query.js";
4
3
  /** Blocks whose values the host cannot rebuild from one observed table; their presence leaves an answer unverified. */
5
4
  const UNREBUILDABLE_DATA_BLOCKS = new Set(["chart", "kpi_card"]);
@@ -7,13 +6,12 @@ function record(value) {
7
6
  return typeof value === "object" && value !== null && !Array.isArray(value) ? value : undefined;
8
7
  }
9
8
  /**
10
- * The queries a codex:local answer names. A `{blocks}` answer cites through its `definition`
11
- * blocks; a flat or query-result answer through its own `definition` / top-level fields, read
12
- * from the raw text because envelope normalization drops a cited `query_id`. A host id wins over
13
- * SQL, like the component path.
9
+ * The queries an answer names. A `{blocks}` answer cites through its `definition` blocks; a flat
10
+ * or query-result answer through its own `definition` / top-level fields, read from `raw` (the
11
+ * answer as the model wrote it) because envelope normalization drops a cited `query_id`. A host
12
+ * id wins over SQL, like the component path.
14
13
  */
15
- function citations(envelope, finalText) {
16
- const raw = extractJsonCandidateFromText(finalText);
14
+ function citations(envelope, raw) {
17
15
  if (!Array.isArray(record(raw)?.blocks)) {
18
16
  const queryId = namedAnswerQueryId(raw);
19
17
  if (queryId !== undefined)
@@ -41,26 +39,29 @@ function citations(envelope, finalText) {
41
39
  return found;
42
40
  }
43
41
  /**
44
- * Grounds a codex:local answer against the queries this turn's host query service executed,
45
- * with the component path's predicate (`citedGroundedQuery`: a tool-proven definition that
46
- * reads at least one table, cited by `query_id`, else by whitespace-insensitive SQL).
42
+ * Grounds an answer against the queries the host executed for its scope (one codex:local or
43
+ * agent-sdk Ask turn, or one native session), with the component path's predicate
44
+ * (`citedGroundedQuery`: a tool-proven definition that reads at least one table, cited by
45
+ * `query_id`, else by whitespace-insensitive SQL).
47
46
  *
48
47
  * Verified only when the answer cites at least one query and every citation grounds. The
49
48
  * envelope is then rebuilt from the observations alone: one `table` and one `definition` block
50
49
  * (carrying the observed `query_id`) per cited query, plus the model's `summary` text as written.
51
50
  * A cited result the host saw truncated still grounds the rows it shows; the host then appends
52
51
  * a note to `summary`, the only place the render contract carries such text. Anything else stays the model's answer with
53
- * `verified: false`: no citation, a citation to a query this turn never ran (including one from
54
- * an earlier turn), a query that read no table, or chart / KPI blocks the host cannot rebuild.
52
+ * `verified: false`: no citation, a citation to a query this scope never ran (including one from
53
+ * another turn or session), a query that read no table, or chart / KPI blocks the host cannot rebuild.
54
+ *
55
+ * `raw` is the answer as written, before envelope normalization (see `citations`).
55
56
  */
56
- export function groundCodexLocalEnvelope(envelope, finalText, observations) {
57
+ export function groundOnHostEvidence(envelope, raw, observations) {
57
58
  const unverified = { ...envelope, verified: false };
58
59
  if (observations.length === 0)
59
60
  return unverified;
60
61
  const blocks = Array.isArray(envelope.blocks) ? envelope.blocks : [];
61
62
  if (blocks.some((block) => UNREBUILDABLE_DATA_BLOCKS.has(String(record(block)?.type))))
62
63
  return unverified;
63
- const cited = citations(envelope, finalText);
64
+ const cited = citations(envelope, raw);
64
65
  if (cited.length === 0)
65
66
  return unverified;
66
67
  const rebuilt = [];
@@ -1,8 +1,10 @@
1
1
  import { type NativeSessionRow, type NativeStructuredAnswerRow, type Store } from "./db.js";
2
2
  import type { EnrichmentBinding } from "./enrichment.js";
3
+ import type { ComponentAccess } from "../harness/components/broker.js";
3
4
  export declare const NATIVE_MCP_CREDENTIAL_ENV_VAR = "WARBLE_MCP_CONNECTION_CREDENTIAL";
4
5
  export declare const NATIVE_MCP_TOOL_NAME = "save_dashboard";
5
6
  export declare const NATIVE_MCP_PERSIST_ANSWER_TOOL_NAME = "persist_answer";
7
+ export declare const NATIVE_MCP_QUERY_TOOL_NAME = "query";
6
8
  export declare const NATIVE_MCP_SERVER_LABEL = "GenBI MCP";
7
9
  export declare const NATIVE_MCP_ARTIFACTS_LABEL = "GenBI Artifacts";
8
10
  /**
@@ -98,7 +100,7 @@ export declare const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA: {
98
100
  readonly properties: {
99
101
  readonly verified: {
100
102
  readonly type: "boolean";
101
- readonly description: "Ignored: the host records verified only for an answer it produced from a grounded component run; anything submitted here is stored as unverified.";
103
+ readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
102
104
  };
103
105
  readonly summary: {
104
106
  readonly anyOf: readonly [{
@@ -286,6 +288,15 @@ export declare const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA: {
286
288
  };
287
289
  };
288
290
  };
291
+ readonly query_id: {
292
+ readonly type: "string";
293
+ readonly not: {
294
+ readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
295
+ };
296
+ readonly minLength: 1;
297
+ readonly maxLength: 64;
298
+ readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
299
+ };
289
300
  };
290
301
  }, {
291
302
  readonly type: "object";
@@ -350,7 +361,7 @@ export declare const NATIVE_PERSIST_ANSWER_INPUT_SCHEMA: {
350
361
  readonly properties: {
351
362
  readonly verified: {
352
363
  readonly type: "boolean";
353
- readonly description: "Ignored: the host records verified only for an answer it produced from a grounded component run; anything submitted here is stored as unverified.";
364
+ readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
354
365
  };
355
366
  readonly blocks: {
356
367
  readonly type: "array";
@@ -434,6 +445,15 @@ export declare const NATIVE_PERSIST_ANSWER_INPUT_SCHEMA: {
434
445
  };
435
446
  };
436
447
  };
448
+ readonly query_id: {
449
+ readonly type: "string";
450
+ readonly not: {
451
+ readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
452
+ };
453
+ readonly minLength: 1;
454
+ readonly maxLength: 64;
455
+ readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
456
+ };
437
457
  };
438
458
  }];
439
459
  };
@@ -590,7 +610,7 @@ export declare const NATIVE_SAVE_DASHBOARD_CONTRACT: {
590
610
  readonly properties: {
591
611
  readonly verified: {
592
612
  readonly type: "boolean";
593
- readonly description: "Ignored: the host records verified only for an answer it produced from a grounded component run; anything submitted here is stored as unverified.";
613
+ readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
594
614
  };
595
615
  readonly summary: {
596
616
  readonly anyOf: readonly [{
@@ -778,6 +798,15 @@ export declare const NATIVE_SAVE_DASHBOARD_CONTRACT: {
778
798
  };
779
799
  };
780
800
  };
801
+ readonly query_id: {
802
+ readonly type: "string";
803
+ readonly not: {
804
+ readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
805
+ };
806
+ readonly minLength: 1;
807
+ readonly maxLength: 64;
808
+ readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
809
+ };
781
810
  };
782
811
  }, {
783
812
  readonly type: "object";
@@ -851,7 +880,7 @@ export declare const NATIVE_PERSIST_ANSWER_CONTRACT: {
851
880
  readonly properties: {
852
881
  readonly verified: {
853
882
  readonly type: "boolean";
854
- readonly description: "Ignored: the host records verified only for an answer it produced from a grounded component run; anything submitted here is stored as unverified.";
883
+ readonly description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps.";
855
884
  };
856
885
  readonly blocks: {
857
886
  readonly type: "array";
@@ -935,6 +964,15 @@ export declare const NATIVE_PERSIST_ANSWER_CONTRACT: {
935
964
  };
936
965
  };
937
966
  };
967
+ readonly query_id: {
968
+ readonly type: "string";
969
+ readonly not: {
970
+ readonly pattern: "<\\s*\\/?(?:[sS][cC][rR][iI][pP][tT]|[hH][tT][mM][lL])\\b|[jJ][aA][vV][aA][sS][cC][rR][iI][pP][tT]\\s*:";
971
+ };
972
+ readonly minLength: 1;
973
+ readonly maxLength: 64;
974
+ readonly description: "Optional: the query_id the query tool returned for the query this definition describes.";
975
+ };
938
976
  };
939
977
  }];
940
978
  };
@@ -956,6 +994,29 @@ export declare const NATIVE_PERSIST_ANSWER_CONTRACT: {
956
994
  };
957
995
  readonly semanticConstraint: "The envelope contains exactly one typed table block and at most one typed definition block, with no presentation-only fields.";
958
996
  };
997
+ /** The session `query` tool: one read-only SQL query run by the host through governed access. */
998
+ export declare const NATIVE_QUERY_INPUT_SCHEMA: {
999
+ readonly type: "object";
1000
+ readonly additionalProperties: false;
1001
+ readonly required: readonly ["sql"];
1002
+ readonly properties: {
1003
+ readonly sql: {
1004
+ readonly type: "string";
1005
+ readonly minLength: 1;
1006
+ readonly description: "One read-only SELECT over the bound semantic models.";
1007
+ };
1008
+ readonly limit: {
1009
+ readonly type: "integer";
1010
+ readonly minimum: 1;
1011
+ readonly description: "Maximum rows to return (default 1000).";
1012
+ };
1013
+ };
1014
+ };
1015
+ export interface NativeQueryResult {
1016
+ readonly isError: boolean;
1017
+ readonly text: string;
1018
+ readonly result?: unknown;
1019
+ }
959
1020
  export interface NativeArtifactServiceOptions {
960
1021
  readonly store: Store;
961
1022
  readonly artifactsRoot: string;
@@ -964,6 +1025,11 @@ export interface NativeArtifactServiceOptions {
964
1025
  /** Optional override, accepted only when it is the expected local endpoint. */
965
1026
  readonly mcpUrl: string;
966
1027
  readonly getBinding: () => EnrichmentBinding | undefined;
1028
+ /**
1029
+ * Opens governed Wren access for one native analysis session's `query` tool, against the
1030
+ * session's bound project. Without it, the tool is not offered.
1031
+ */
1032
+ readonly openQueryAccess?: (binding: EnrichmentBinding, signal: AbortSignal) => Promise<ComponentAccess>;
967
1033
  /** Test-only seam; production writes one contained JSON file atomically. */
968
1034
  readonly writeAtomic?: (target: string, contents: string) => void;
969
1035
  /** Test-only seam for a persistence outage; production uses the typed Store method. */
@@ -982,11 +1048,32 @@ export interface NativeArtifactServiceOptions {
982
1048
  export declare class NativeArtifactService {
983
1049
  private readonly options;
984
1050
  private readonly credentials;
1051
+ /** One host query recorder per native session: its observations are never shared across sessions. */
1052
+ private readonly queryRecorders;
1053
+ private readonly queryClosing;
985
1054
  constructor(options: NativeArtifactServiceOptions);
986
1055
  /** Validate the exact local BFF endpoint before advertising or issuing it. */
987
1056
  readiness(): NativeMcpReadiness;
988
1057
  issue(session: NativeSessionRow, binding: EnrichmentBinding | undefined): NativeMcpDescriptor;
989
1058
  revoke(credential: string | undefined): void;
1059
+ /** Whether native analysis sessions are offered the host `query` tool. */
1060
+ queryAvailable(): boolean;
1061
+ /** Resolves once every session query recorder closed so far has released its governed access. */
1062
+ queriesClosed(): Promise<void>;
1063
+ private closeQueries;
1064
+ /**
1065
+ * The session `query` tool: runs one read-only query through governed access against the
1066
+ * session's bound project, records it as this session's observation and returns its rows with
1067
+ * a session-tagged host `query_id` that a later persist_answer or save_dashboard may cite.
1068
+ */
1069
+ query(credential: string | undefined, input: unknown): Promise<NativeQueryResult>;
1070
+ /**
1071
+ * The host's grounding decision for a model-written envelope: the same envelope rebuilt from
1072
+ * this session's query observations when every query it cites grounds on them (see
1073
+ * `groundOnHostEvidence`), else `undefined`. Only this decision reaches a grounded entry from
1074
+ * the MCP surface.
1075
+ */
1076
+ private groundedEnvelope;
990
1077
  /** Releases opaque credentials when the owning BFF service shuts down. */
991
1078
  dispose(): void;
992
1079
  hasCredential(credential: string | undefined): boolean;
@@ -1016,5 +1103,7 @@ export declare class NativeArtifactService {
1016
1103
  * MCP). The caller must already have checked verified provenance.
1017
1104
  */
1018
1105
  saveGrounded(credential: string | undefined, input: unknown): SavedNativeArtifact;
1106
+ /** The credential, session and binding checks every save runs before it reads its payload. */
1107
+ private authorizeSave;
1019
1108
  private saveArtifact;
1020
1109
  }
@@ -7,6 +7,8 @@ import { createHash, randomUUID } from "node:crypto";
7
7
  import { lstatSync, mkdirSync, realpathSync, renameSync, rmSync, writeFileSync } from "node:fs";
8
8
  import path from "node:path";
9
9
  import { newId } from "./db.js";
10
+ import { createHostQueryRecorder } from "../harness/route/host-query.js";
11
+ import { groundOnHostEvidence } from "./host-evidence-grounding.js";
10
12
  const MAX_DASHBOARD_BYTES = 256 * 1024;
11
13
  const MAX_NAME_LENGTH = 120;
12
14
  const MAX_IDEMPOTENCY_KEY_LENGTH = 128;
@@ -14,6 +16,7 @@ const ANSWER_REFERENCE_PATTERN = /^answer-[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{
14
16
  export const NATIVE_MCP_CREDENTIAL_ENV_VAR = "WARBLE_MCP_CONNECTION_CREDENTIAL";
15
17
  export const NATIVE_MCP_TOOL_NAME = "save_dashboard";
16
18
  export const NATIVE_MCP_PERSIST_ANSWER_TOOL_NAME = "persist_answer";
19
+ export const NATIVE_MCP_QUERY_TOOL_NAME = "query";
17
20
  export const NATIVE_MCP_SERVER_LABEL = "GenBI MCP";
18
21
  export const NATIVE_MCP_ARTIFACTS_LABEL = "GenBI Artifacts";
19
22
  // Keep this JSON Schema compatible with the ECMA-262 regular-expression
@@ -53,7 +56,8 @@ const POSITIONAL_ROWS_SCHEMA = {
53
56
  items: { type: "array", items: SCALAR_SCHEMA },
54
57
  };
55
58
  /** The exact public MCP shape advertised to native clients and accepted by the host. */
56
- const VERIFIED_FIELD_SCHEMA = { type: "boolean", description: "Ignored: the host records verified only for an answer it produced from a grounded component run; anything submitted here is stored as unverified." };
59
+ const VERIFIED_FIELD_SCHEMA = { type: "boolean", description: "Ignored: the host decides verified itself. It records verified only for an answer it grounded: a grounded component run, or an answer whose every definition cites a query this session ran through the query tool. Anything submitted here is not a claim the host keeps." };
60
+ const QUERY_ID_SCHEMA = { ...SAFE_TEXT_SCHEMA, minLength: 1, maxLength: 64, description: "Optional: the query_id the query tool returned for the query this definition describes." };
57
61
  export const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA = {
58
62
  type: "object",
59
63
  additionalProperties: false,
@@ -79,7 +83,7 @@ export const NATIVE_SAVE_DASHBOARD_INPUT_SCHEMA = {
79
83
  { type: "object", additionalProperties: false, required: ["type", "label", "value"], properties: { type: { const: "kpi_card" }, label: SAFE_TEXT_SCHEMA, value: SAFE_TEXT_OR_NUMBER_SCHEMA, unit: NULLABLE_SAFE_TEXT_SCHEMA, delta: { type: ["number", "null"] } } },
80
84
  { type: "object", additionalProperties: false, required: ["type", "columns", "rows"], properties: { type: { const: "table" }, columns: { type: "array", minItems: 1, uniqueItems: true, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, rows: { ...POSITIONAL_ROWS_SCHEMA, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.table.positionalRows } } },
81
85
  { type: "object", additionalProperties: false, required: ["type", "chart_type", "x", "series", "rows"], properties: { type: { const: "chart" }, chart_type: { enum: ["bar", "line", "pie", "area", "scatter"] }, x: { ...SAFE_TEXT_SCHEMA, minLength: 1, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.chart.x }, series: { type: "array", minItems: 1, uniqueItems: true, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.chart.series, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, rows: { ...POSITIONAL_ROWS_SCHEMA, description: NATIVE_SAVE_DASHBOARD_SEMANTIC_CONSTRAINTS.chart.positionalRows } } },
82
- { type: "object", additionalProperties: false, required: ["type", "sql", "source_tables", "filters"], properties: { type: { const: "definition" }, sql: { ...SAFE_TEXT_SCHEMA, minLength: 1 }, source_tables: { type: "array", minItems: 1, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, filters: { type: "array", items: SAFE_TEXT_SCHEMA } } },
86
+ { type: "object", additionalProperties: false, required: ["type", "sql", "source_tables", "filters"], properties: { type: { const: "definition" }, sql: { ...SAFE_TEXT_SCHEMA, minLength: 1 }, source_tables: { type: "array", minItems: 1, items: { ...SAFE_TEXT_SCHEMA, minLength: 1 } }, filters: { type: "array", items: SAFE_TEXT_SCHEMA }, query_id: QUERY_ID_SCHEMA } },
83
87
  { type: "object", additionalProperties: false, required: ["type", "text"], properties: { type: { const: "narrative" }, text: { ...SAFE_TEXT_SCHEMA, minLength: 1 }, title: NULLABLE_SAFE_TEXT_SCHEMA } },
84
88
  ] },
85
89
  },
@@ -163,6 +167,30 @@ export const NATIVE_PERSIST_ANSWER_CONTRACT = {
163
167
  inputSchema: NATIVE_PERSIST_ANSWER_INPUT_SCHEMA,
164
168
  semanticConstraint: "The envelope contains exactly one typed table block and at most one typed definition block, with no presentation-only fields.",
165
169
  };
170
+ /** The session `query` tool: one read-only SQL query run by the host through governed access. */
171
+ export const NATIVE_QUERY_INPUT_SCHEMA = {
172
+ type: "object",
173
+ additionalProperties: false,
174
+ required: ["sql"],
175
+ properties: {
176
+ sql: { type: "string", minLength: 1, description: "One read-only SELECT over the bound semantic models." },
177
+ limit: { type: "integer", minimum: 1, description: "Maximum rows to return (default 1000)." },
178
+ },
179
+ };
180
+ /** The sentence the model sees per host error class; nothing from the governed transport is forwarded. */
181
+ const NATIVE_QUERY_ERROR_TEXT = {
182
+ invalid_request: "The request did not match the query contract: pass a non-empty sql string and an optional positive integer limit.",
183
+ model_not_found: "The query references a table that is not a model in the bound semantic context; query only the models it defines.",
184
+ policy_rejected: "The read-only analytical policy rejected the query: use one SELECT that reads at least one bound model.",
185
+ invalid_sql: "The SQL could not be parsed or planned against the semantic context.",
186
+ execution_failed: "The data source rejected the query at execution time, for example an unknown column or a type mismatch.",
187
+ timeout: "The query exceeded the statement time limit.",
188
+ datasource_unavailable: "The bound data source could not be reached.",
189
+ result_too_large: "The result exceeded the governed byte limit; narrow the query.",
190
+ budget_exhausted: "This session's query budget is exhausted; answer from the results you already have.",
191
+ internal: "The governed query failed for an unclassified reason.",
192
+ transport: "The host query service is unavailable.",
193
+ };
166
194
  function rejectsExecutableText(value) {
167
195
  if (typeof value === "string")
168
196
  return /<\s*\/?(?:script|html)\b|javascript\s*:/i.test(value);
@@ -202,7 +230,7 @@ function validBlock(block) {
202
230
  // Existing object rows remain exactly bound to x + series.
203
231
  : isRecord(row) && scalar(row[x]) && series.every((item) => scalar(row[item])) && Object.keys(row).every((key) => key === x || series.includes(key)));
204
232
  case "definition":
205
- return onlyKeys(block, ["type", "sql", "source_tables", "filters"]) && typeof block.sql === "string" && block.sql.length > 0 && Array.isArray(block.source_tables) && block.source_tables.length > 0 && block.source_tables.every((source) => typeof source === "string" && source.length > 0) && Array.isArray(block.filters) && block.filters.every((filter) => typeof filter === "string");
233
+ return (onlyKeys(block, ["type", "sql", "source_tables", "filters"]) || (onlyKeys(block, ["type", "sql", "source_tables", "filters", "query_id"]) && typeof block.query_id === "string" && block.query_id.length > 0 && block.query_id.length <= 64)) && typeof block.sql === "string" && block.sql.length > 0 && Array.isArray(block.source_tables) && block.source_tables.length > 0 && block.source_tables.every((source) => typeof source === "string" && source.length > 0) && Array.isArray(block.filters) && block.filters.every((filter) => typeof filter === "string");
206
234
  case "narrative":
207
235
  return (onlyKeys(block, ["type", "text"]) || onlyKeys(block, ["type", "text", "title"])) && typeof block.text === "string" && block.text.length > 0 && (block.title === undefined || block.title === null || typeof block.title === "string");
208
236
  default:
@@ -259,6 +287,24 @@ function validateInput(value) {
259
287
  throw new NativeArtifactError("invalid Save to Artifacts payload: answer_selection must be latest");
260
288
  return { version: "1", name: value.name, answer_selection: "latest", idempotency_key: value.idempotency_key };
261
289
  }
290
+ function isValidSaveInput(value) {
291
+ try {
292
+ validateInput(value);
293
+ return true;
294
+ }
295
+ catch {
296
+ return false;
297
+ }
298
+ }
299
+ function isValidPersistedStructuredAnswer(value) {
300
+ try {
301
+ validatePersistedStructuredAnswer(value);
302
+ return true;
303
+ }
304
+ catch {
305
+ return false;
306
+ }
307
+ }
262
308
  function validatePersistedStructuredAnswer(value) {
263
309
  if (!isRecord(value) || !onlyKeys(value, ["version", "idempotency_key", "envelope"]))
264
310
  throw new NativeArtifactError("invalid structured answer payload: expected exactly version, idempotency_key, and envelope");
@@ -305,6 +351,9 @@ function validateStoredStructuredAnswer(source, nativeSessionId) {
305
351
  export class NativeArtifactService {
306
352
  options;
307
353
  credentials = new Map();
354
+ /** One host query recorder per native session: its observations are never shared across sessions. */
355
+ queryRecorders = new Map();
356
+ queryClosing = new Set();
308
357
  constructor(options) {
309
358
  this.options = options;
310
359
  }
@@ -336,7 +385,78 @@ export class NativeArtifactService {
336
385
  revoke(credential) {
337
386
  if (!credential)
338
387
  return;
388
+ const context = this.credentials.get(credential);
339
389
  this.credentials.delete(credential);
390
+ // A session can hold more than one credential (a Codex session's terminal and its component
391
+ // runtime each get one). Its query recorder lives until the session ends: the last credential
392
+ // for the session is revoked, which every session end and `dispose()` do.
393
+ if (context && ![...this.credentials.values()].some((other) => other.session.id === context.session.id))
394
+ this.closeQueries(context.session.id);
395
+ }
396
+ /** Whether native analysis sessions are offered the host `query` tool. */
397
+ queryAvailable() { return this.options.openQueryAccess !== undefined; }
398
+ /** Resolves once every session query recorder closed so far has released its governed access. */
399
+ async queriesClosed() { await Promise.allSettled([...this.queryClosing]); }
400
+ closeQueries(sessionId) {
401
+ const recorder = this.queryRecorders.get(sessionId);
402
+ if (!recorder)
403
+ return;
404
+ this.queryRecorders.delete(sessionId);
405
+ const closing = recorder.close();
406
+ this.queryClosing.add(closing);
407
+ void closing.finally(() => this.queryClosing.delete(closing));
408
+ }
409
+ /**
410
+ * The session `query` tool: runs one read-only query through governed access against the
411
+ * session's bound project, records it as this session's observation and returns its rows with
412
+ * a session-tagged host `query_id` that a later persist_answer or save_dashboard may cite.
413
+ */
414
+ async query(credential, input) {
415
+ const session = this.authorize(credential);
416
+ const binding = this.credentials.get(credential)?.binding;
417
+ const openQueryAccess = this.options.openQueryAccess;
418
+ if (session.purpose !== "analysis" || !binding || !openQueryAccess)
419
+ throw new NativeArtifactError("the query tool is unavailable for this native session", 409);
420
+ const args = isRecord(input) ? input : {};
421
+ if (!isRecord(input) || !Object.keys(input).every((key) => key === "sql" || key === "limit"))
422
+ return { isError: true, text: NATIVE_QUERY_ERROR_TEXT.invalid_request };
423
+ let recorder = this.queryRecorders.get(session.id);
424
+ if (!recorder) {
425
+ recorder = createHostQueryRecorder({ openAccess: (signal) => openQueryAccess(binding, signal) });
426
+ this.queryRecorders.set(session.id, recorder);
427
+ }
428
+ const outcome = await recorder.execute(args.sql, args.limit);
429
+ if ("error" in outcome)
430
+ return { isError: true, text: `Governed Wren query failed [${outcome.error}]: ${NATIVE_QUERY_ERROR_TEXT[outcome.error] ?? NATIVE_QUERY_ERROR_TEXT.internal}` };
431
+ return { isError: false, text: JSON.stringify(outcome.result), result: outcome.result };
432
+ }
433
+ /**
434
+ * The host's grounding decision for a model-written envelope: the same envelope rebuilt from
435
+ * this session's query observations when every query it cites grounds on them (see
436
+ * `groundOnHostEvidence`), else `undefined`. Only this decision reaches a grounded entry from
437
+ * the MCP surface.
438
+ */
439
+ groundedEnvelope(credential, envelope) {
440
+ const context = credential === undefined ? undefined : this.credentials.get(credential);
441
+ const recorder = context ? this.queryRecorders.get(context.session.id) : undefined;
442
+ if (!recorder || !isRecord(envelope))
443
+ return undefined;
444
+ // The host rebuilds only table and definition blocks. Any other block (narrative, chart, KPI)
445
+ // or an `estimate` flag would be dropped by the rebuild, so such an envelope is never grounded:
446
+ // it is kept intact and stored unverified rather than losing content silently.
447
+ if (envelope.estimate !== undefined && envelope.estimate !== null)
448
+ return undefined;
449
+ if (!Array.isArray(envelope.blocks) || envelope.blocks.some((block) => !isRecord(block) || (block.type !== "table" && block.type !== "definition")))
450
+ return undefined;
451
+ const grounded = groundOnHostEvidence(envelope, envelope, recorder.observations());
452
+ if (grounded.verified !== true)
453
+ return undefined;
454
+ // The rebuild emits one table and one definition per cited query. If it has fewer of either
455
+ // than the envelope did (an extra uncited table, say), content would be lost: keep it intact.
456
+ const count = (value, type) => Array.isArray(value.blocks) ? value.blocks.filter((block) => isRecord(block) && block.type === type).length : 0;
457
+ if (count(grounded, "table") !== count(envelope, "table") || count(grounded, "definition") !== count(envelope, "definition"))
458
+ return undefined;
459
+ return grounded;
340
460
  }
341
461
  /** Releases opaque credentials when the owning BFF service shuts down. */
342
462
  dispose() { for (const credential of [...this.credentials.keys()])
@@ -386,6 +506,20 @@ export class NativeArtifactService {
386
506
  * a later follow-up can only reference this stored result, never recompute.
387
507
  */
388
508
  persistAnswer(credential, input) {
509
+ // Authorize before validating, as `persistStructured` does: a bad credential is a 401 and a
510
+ // non-analysis session a 409 whatever the payload.
511
+ const session = this.authorize(credential);
512
+ if (session.purpose !== "analysis")
513
+ throw new NativeArtifactError("structured answer persistence is unavailable for this native session", 409);
514
+ const payload = validatePersistedStructuredAnswer(input);
515
+ const grounded = this.groundedEnvelope(credential, payload.envelope);
516
+ if (grounded) {
517
+ const candidate = { ...payload, envelope: grounded };
518
+ // A rebuilt envelope the persist contract cannot hold (a truncation note needs a summary,
519
+ // which this contract has no field for) is persisted unverified, as written.
520
+ if (isValidPersistedStructuredAnswer(candidate))
521
+ return this.persistStructured(credential, candidate, true);
522
+ }
389
523
  return this.persistStructured(credential, input, false);
390
524
  }
391
525
  /**
@@ -428,6 +562,16 @@ export class NativeArtifactService {
428
562
  }
429
563
  }
430
564
  save(credential, input) {
565
+ // Authorize before validating, as `saveArtifact` does.
566
+ this.authorizeSave(credential);
567
+ const payload = validateInput(input);
568
+ // Only an envelope save is grounded here; a save by reference inherits the retained answer's provenance.
569
+ const grounded = "envelope" in payload ? this.groundedEnvelope(credential, payload.envelope) : undefined;
570
+ if (grounded) {
571
+ const candidate = { ...payload, envelope: grounded };
572
+ if (isValidSaveInput(candidate))
573
+ return this.saveArtifact(credential, candidate, true);
574
+ }
431
575
  return this.saveArtifact(credential, input, false);
432
576
  }
433
577
  /**
@@ -437,7 +581,8 @@ export class NativeArtifactService {
437
581
  saveGrounded(credential, input) {
438
582
  return this.saveArtifact(credential, input, true);
439
583
  }
440
- saveArtifact(credential, input, grounded) {
584
+ /** The credential, session and binding checks every save runs before it reads its payload. */
585
+ authorizeSave(credential) {
441
586
  const context = credential === undefined ? undefined : this.credentials.get(credential);
442
587
  if (!context)
443
588
  throw new NativeArtifactError("GenBI MCP bearer credential is invalid. Restart this native session to refresh its GenBI MCP connection.", 401);
@@ -447,6 +592,10 @@ export class NativeArtifactService {
447
592
  this.revoke(credential);
448
593
  throw new NativeArtifactError("GenBI MCP session binding is stale. Start a new native session.", 409);
449
594
  }
595
+ return { session, binding };
596
+ }
597
+ saveArtifact(credential, input, grounded) {
598
+ const { session, binding } = this.authorizeSave(credential);
450
599
  const payload = validateInput(input);
451
600
  const source = "answer_ref" in payload
452
601
  ? this.options.store.getNativeStructuredAnswer(payload.answer_ref)
@@ -604,7 +604,10 @@ async function executeSetupTurn(deps, session, turn, emit, signal) {
604
604
  ? undefined
605
605
  : deps.store.getSetupContextLifecycleEvidence(session.id, identityFingerprint)?.completed;
606
606
  if (identityFingerprint !== undefined) {
607
- const recorded = recordedContextLifecyclePrefix(worklog, prior ?? "none");
607
+ const recorded = recordedContextLifecyclePrefix(worklog, prior ?? "none", {
608
+ root: workspaceRoot,
609
+ projectDir: path.join(workspaceRoot, form.projectName),
610
+ });
608
611
  // A build command is not retained by itself: only a successful terminal
609
612
  // parse has independently checked target/mdl.json. Discovery/validate
610
613
  // remain useful across an interrupted turn, but build includes artifact
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@wrenai/genbi",
3
- "version": "0.0.5",
3
+ "version": "0.0.6",
4
4
  "description": "Wren GenBI — the web UI for WrenAI: ask questions of your data and get dashboards back",
5
5
  "license": "Apache-2.0",
6
6
  "keywords": [
@@ -39,7 +39,7 @@
39
39
  "@warble/cli": "0.17.0",
40
40
  "@warble/codex-local": "0.17.0",
41
41
  "@warble/ir-spec": "0.9.0",
42
- "@wrenai/context-loader": "0.1.3",
42
+ "@wrenai/context-loader": "0.1.4",
43
43
  "@xterm/addon-fit": "^0.11.0",
44
44
  "@xterm/xterm": "^6.0.0",
45
45
  "ai": "~7.0.28",