@pipeshub-ai/mcp 2.1.0 → 2.3.0

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 (146) hide show
  1. package/README.md +92 -2
  2. package/bin/mcp-server.js +976 -169
  3. package/bin/mcp-server.js.map +28 -15
  4. package/bin/pipeshub.js +946 -0
  5. package/bin/pipeshub.js.map +14 -0
  6. package/esm/cli/client.d.ts +54 -0
  7. package/esm/cli/client.d.ts.map +1 -0
  8. package/esm/cli/client.js +230 -0
  9. package/esm/cli/client.js.map +1 -0
  10. package/esm/cli/commands.d.ts +30 -0
  11. package/esm/cli/commands.d.ts.map +1 -0
  12. package/esm/cli/commands.js +321 -0
  13. package/esm/cli/commands.js.map +1 -0
  14. package/esm/cli/config.d.ts +73 -0
  15. package/esm/cli/config.d.ts.map +1 -0
  16. package/esm/cli/config.js +218 -0
  17. package/esm/cli/config.js.map +1 -0
  18. package/esm/cli/init-qm.d.ts +11 -0
  19. package/esm/cli/init-qm.d.ts.map +1 -0
  20. package/esm/cli/init-qm.js +170 -0
  21. package/esm/cli/init-qm.js.map +1 -0
  22. package/esm/cli/pipeshub.d.ts +2 -0
  23. package/esm/cli/pipeshub.d.ts.map +1 -0
  24. package/esm/cli/pipeshub.js +296 -0
  25. package/esm/cli/pipeshub.js.map +1 -0
  26. package/esm/funcs/connectorGetRecordContent.d.ts +43 -0
  27. package/esm/funcs/connectorGetRecordContent.d.ts.map +1 -0
  28. package/esm/funcs/connectorGetRecordContent.js +114 -0
  29. package/esm/funcs/connectorGetRecordContent.js.map +1 -0
  30. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts +68 -0
  31. package/esm/funcs/connectorLookupRecordByIdentifier.d.ts.map +1 -0
  32. package/esm/funcs/connectorLookupRecordByIdentifier.js +138 -0
  33. package/esm/funcs/connectorLookupRecordByIdentifier.js.map +1 -0
  34. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts +75 -0
  35. package/esm/funcs/connectorNavigateKnowledgeGraph.d.ts.map +1 -0
  36. package/esm/funcs/connectorNavigateKnowledgeGraph.js +152 -0
  37. package/esm/funcs/connectorNavigateKnowledgeGraph.js.map +1 -0
  38. package/esm/funcs/conversationsStreamConversation.js +1 -1
  39. package/esm/funcs/conversationsStreamConversation.js.map +1 -1
  40. package/esm/funcs/userGroupsGetAllUserGroups.js +1 -1
  41. package/esm/funcs/userGroupsGetAllUserGroups.js.map +1 -1
  42. package/esm/hooks/registration.d.ts.map +1 -1
  43. package/esm/hooks/registration.js +2 -1
  44. package/esm/hooks/registration.js.map +1 -1
  45. package/esm/hooks/request-context.d.ts +10 -0
  46. package/esm/hooks/request-context.d.ts.map +1 -0
  47. package/esm/hooks/request-context.js +38 -0
  48. package/esm/hooks/request-context.js.map +1 -0
  49. package/esm/hooks/requestid.d.ts +5 -0
  50. package/esm/hooks/requestid.d.ts.map +1 -0
  51. package/esm/hooks/requestid.js +58 -0
  52. package/esm/hooks/requestid.js.map +1 -0
  53. package/esm/lib/base64.d.ts +1 -1
  54. package/esm/lib/base64.d.ts.map +1 -1
  55. package/esm/lib/base64.js +6 -3
  56. package/esm/lib/base64.js.map +1 -1
  57. package/esm/lib/bytes-to-base64.d.ts +16 -0
  58. package/esm/lib/bytes-to-base64.d.ts.map +1 -0
  59. package/esm/lib/bytes-to-base64.js +23 -0
  60. package/esm/lib/bytes-to-base64.js.map +1 -0
  61. package/esm/mcp-server/instructions.d.ts +1 -1
  62. package/esm/mcp-server/instructions.d.ts.map +1 -1
  63. package/esm/mcp-server/instructions.js +62 -9
  64. package/esm/mcp-server/instructions.js.map +1 -1
  65. package/esm/mcp-server/server.d.ts.map +1 -1
  66. package/esm/mcp-server/server.js +10 -3
  67. package/esm/mcp-server/server.js.map +1 -1
  68. package/esm/mcp-server/shared.d.ts +2 -2
  69. package/esm/mcp-server/shared.d.ts.map +1 -1
  70. package/esm/mcp-server/shared.js +7 -3
  71. package/esm/mcp-server/shared.js.map +1 -1
  72. package/esm/mcp-server/tools/_agui.d.ts +34 -0
  73. package/esm/mcp-server/tools/_agui.d.ts.map +1 -0
  74. package/esm/mcp-server/tools/_agui.js +91 -0
  75. package/esm/mcp-server/tools/_agui.js.map +1 -0
  76. package/esm/mcp-server/tools/_helpers.d.ts.map +1 -1
  77. package/esm/mcp-server/tools/_helpers.js +13 -6
  78. package/esm/mcp-server/tools/_helpers.js.map +1 -1
  79. package/esm/mcp-server/tools/pipeshubChat.d.ts +0 -3
  80. package/esm/mcp-server/tools/pipeshubChat.d.ts.map +1 -1
  81. package/esm/mcp-server/tools/pipeshubChat.js +75 -92
  82. package/esm/mcp-server/tools/pipeshubChat.js.map +1 -1
  83. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts +125 -0
  84. package/esm/mcp-server/tools/pipeshubGetRecordContent.d.ts.map +1 -0
  85. package/esm/mcp-server/tools/pipeshubGetRecordContent.js +214 -0
  86. package/esm/mcp-server/tools/pipeshubGetRecordContent.js.map +1 -0
  87. package/esm/mcp-server/tools/pipeshubSearch.d.ts.map +1 -1
  88. package/esm/mcp-server/tools/pipeshubSearch.js +22 -5
  89. package/esm/mcp-server/tools/pipeshubSearch.js.map +1 -1
  90. package/esm/mcp-server/tools.d.ts.map +1 -1
  91. package/esm/mcp-server/tools.js +13 -1
  92. package/esm/mcp-server/tools.js.map +1 -1
  93. package/esm/models/availablemodelsresponse.d.ts +1 -1
  94. package/esm/models/conversation.d.ts +1 -1
  95. package/esm/models/getrecordcontentop.d.ts +6 -0
  96. package/esm/models/getrecordcontentop.d.ts.map +1 -0
  97. package/esm/models/getrecordcontentop.js +5 -0
  98. package/esm/models/getrecordcontentop.js.map +1 -0
  99. package/esm/models/lookuprecordbyidentifierop.d.ts +7 -0
  100. package/esm/models/lookuprecordbyidentifierop.d.ts.map +1 -0
  101. package/esm/models/lookuprecordbyidentifierop.js +14 -0
  102. package/esm/models/lookuprecordbyidentifierop.js.map +1 -0
  103. package/esm/models/navigateknowledgegraphop.d.ts +14 -0
  104. package/esm/models/navigateknowledgegraphop.d.ts.map +1 -0
  105. package/esm/models/navigateknowledgegraphop.js +25 -0
  106. package/esm/models/navigateknowledgegraphop.js.map +1 -0
  107. package/esm/models/userteamsresponse.d.ts +1 -1
  108. package/esm/tool-names.d.ts.map +1 -1
  109. package/esm/tool-names.js +6 -2
  110. package/esm/tool-names.js.map +1 -1
  111. package/package.json +4 -2
  112. package/qm/README.md +247 -0
  113. package/qm/SECURITY.md +134 -0
  114. package/qm/TROUBLESHOOTING.md +218 -0
  115. package/qm/qm.config.fragment.jsonc +42 -0
  116. package/qm/sandbox/Dockerfile +22 -0
  117. package/qm/sandbox/skills/pipeshub/SKILL.md +69 -0
  118. package/qm/sandbox/tools/pipeshub/tool.json +36 -0
  119. package/src/cli/client.ts +272 -0
  120. package/src/cli/commands.ts +403 -0
  121. package/src/cli/config.ts +229 -0
  122. package/src/cli/init-qm.ts +215 -0
  123. package/src/cli/pipeshub.ts +333 -0
  124. package/src/funcs/connectorGetRecordContent.ts +179 -0
  125. package/src/funcs/connectorLookupRecordByIdentifier.ts +200 -0
  126. package/src/funcs/connectorNavigateKnowledgeGraph.ts +215 -0
  127. package/src/funcs/conversationsStreamConversation.ts +1 -1
  128. package/src/funcs/userGroupsGetAllUserGroups.ts +1 -1
  129. package/src/hooks/registration.ts +2 -1
  130. package/src/hooks/request-context.ts +47 -0
  131. package/src/hooks/requestid.ts +62 -0
  132. package/src/lib/base64.ts +6 -3
  133. package/src/lib/bytes-to-base64.ts +24 -0
  134. package/src/mcp-server/instructions.ts +62 -9
  135. package/src/mcp-server/server.ts +11 -3
  136. package/src/mcp-server/shared.ts +7 -3
  137. package/src/mcp-server/tools/_agui.ts +138 -0
  138. package/src/mcp-server/tools/_helpers.ts +13 -6
  139. package/src/mcp-server/tools/pipeshubChat.ts +78 -91
  140. package/src/mcp-server/tools/pipeshubGetRecordContent.ts +251 -0
  141. package/src/mcp-server/tools/pipeshubSearch.ts +22 -5
  142. package/src/mcp-server/tools.ts +14 -1
  143. package/src/models/getrecordcontentop.ts +11 -0
  144. package/src/models/lookuprecordbyidentifierop.ts +26 -0
  145. package/src/models/navigateknowledgegraphop.ts +56 -0
  146. package/src/tool-names.ts +6 -2
@@ -0,0 +1,62 @@
1
+ /*
2
+ * Stamp an x-request-id trace header onto every outbound SDK request, reusing
3
+ * the id bound for the current MCP tool/resource/prompt call (see
4
+ * request-context.ts) so all backend calls from one invocation share it.
5
+ *
6
+ * Format mirrors the TypeScript SDK's own RequestIDHook: `mcp-<userId>-<random>`
7
+ * when the bearer JWT carries a userId claim, else `mcp-<random>`. The userId
8
+ * is only knowable once a request actually carries a resolved Authorization
9
+ * header, so it's decoded off the first outbound request of the tool call and
10
+ * cached on the bound context for every subsequent call to reuse verbatim.
11
+ */
12
+
13
+ import { getRequestContextStore, newRequestId } from "./request-context.js";
14
+ import { BeforeRequestContext, BeforeRequestHook } from "./types.js";
15
+
16
+ const REQUEST_ID_HEADER = "x-request-id";
17
+
18
+ /** Decodes the JWT payload without verifying the signature. */
19
+ function decodeJwtPayload(token: string): Record<string, unknown> | null {
20
+ try {
21
+ const base64Url = token.split(".")[1];
22
+ if (!base64Url) return null;
23
+ const base64 = base64Url.replace(/-/g, "+").replace(/_/g, "/");
24
+ const padded = base64 + "=".repeat((4 - (base64.length % 4)) % 4);
25
+ const claims = JSON.parse(Buffer.from(padded, "base64").toString("utf8"));
26
+ return typeof claims === "object" && claims !== null ? claims : null;
27
+ } catch {
28
+ return null;
29
+ }
30
+ }
31
+
32
+ function userIdFromRequest(request: Request): string | null {
33
+ const auth = request.headers.get("authorization") ?? "";
34
+ if (!auth.toLowerCase().startsWith("bearer ")) return null;
35
+ const token = auth.slice(7).trim();
36
+ const claims = decodeJwtPayload(token);
37
+ return (claims?.["userId"] as string) || null;
38
+ }
39
+
40
+ export class RequestIDHook implements BeforeRequestHook {
41
+ beforeRequest(_hookCtx: BeforeRequestContext, request: Request): Request {
42
+ if (request.headers.get(REQUEST_ID_HEADER)) {
43
+ return request;
44
+ }
45
+
46
+ const store = getRequestContextStore();
47
+ if (!store) {
48
+ // No bound tool-call context (shouldn't happen in practice).
49
+ request.headers.set(REQUEST_ID_HEADER, newRequestId());
50
+ return request;
51
+ }
52
+
53
+ if (!store.finalId) {
54
+ const userId = userIdFromRequest(request);
55
+ store.finalId = userId
56
+ ? `mcp-${userId}-${store.random}`
57
+ : `mcp-${store.random}`;
58
+ }
59
+ request.headers.set(REQUEST_ID_HEADER, store.finalId);
60
+ return request;
61
+ }
62
+ }
package/src/lib/base64.ts CHANGED
@@ -1,12 +1,15 @@
1
1
  /*
2
2
  * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
3
+ *
4
+ * Exception: `bytesToBase64` is re-exported from the hand-maintained
5
+ * `./bytes-to-base64.js` (see that file and `.genignore`). Do not restore
6
+ * the generated one-liner — it overflows the stack on binary payloads.
3
7
  */
4
8
 
5
9
  import * as z from "zod";
6
10
 
7
- export function bytesToBase64(u8arr: Uint8Array): string {
8
- return btoa(String.fromCodePoint(...u8arr));
9
- }
11
+ export { bytesToBase64 } from "./bytes-to-base64.js";
12
+ import { bytesToBase64 } from "./bytes-to-base64.js";
10
13
 
11
14
  export function bytesFromBase64(encoded: string): Uint8Array {
12
15
  return Uint8Array.from(atob(encoded), (c) => c.charCodeAt(0));
@@ -0,0 +1,24 @@
1
+ /**
2
+ * Hand-maintained base64 encoder for binary payloads.
3
+ *
4
+ * Speakeasy's generated helper is:
5
+ * btoa(String.fromCodePoint(...u8arr))
6
+ * which passes one argument per byte and throws "Maximum call stack size
7
+ * exceeded" once the array is larger than the engine's argument limit
8
+ * (between 64 KB and 128 KB on Node 24). That is smaller than a typical
9
+ * PDF or image, so binary MCP results fail outright.
10
+ *
11
+ * Keep this file out of Speakeasy regeneration (see `.genignore`). The
12
+ * generated wrappers in `src/lib/base64.ts` and `src/mcp-server/shared.ts`
13
+ * re-export / call through here.
14
+ */
15
+
16
+ const BASE64_CHUNK = 0x8000;
17
+
18
+ export function bytesToBase64(u8arr: Uint8Array): string {
19
+ let binary = "";
20
+ for (let i = 0; i < u8arr.length; i += BASE64_CHUNK) {
21
+ binary += String.fromCharCode(...u8arr.subarray(i, i + BASE64_CHUNK));
22
+ }
23
+ return btoa(binary);
24
+ }
@@ -9,14 +9,35 @@ PipesHub is the user's workplace AI platform. It indexes their documents,
9
9
  knowledge base content, and connector sources (Drive, Box, Confluence,
10
10
  Slack, Jira, Gmail, ...). When in doubt, the answer is in PipesHub.
11
11
 
12
+ ## Full-document tasks: \`pipeshub_search\` → \`pipeshub_get_record_content\`
13
+
14
+ \`pipeshub_chat\` answers from a handful of retrieved passages — it never
15
+ reads a whole document. Whenever the task depends on a document's
16
+ COMPLETE content, fetch the document itself:
17
+
18
+ 1. \`pipeshub_search\` with the document's name / topic.
19
+ 2. Take the top hit's \`recordId\`.
20
+ 3. \`pipeshub_get_record_content\`, and answer from the returned content.
21
+
22
+ Tasks that need this path — anything where missing part of the document
23
+ could make the answer wrong:
24
+
25
+ - Summarize / TL;DR / key points / takeaways / action items of a doc.
26
+ - Extract or list ALL of something (dates, owners, requirements, ...).
27
+ - Check whether / where a doc mentions something.
28
+ - Translate, rewrite, outline, review, or reformat a doc.
29
+ - Compare named docs (fetch each \`recordId\`).
30
+ - Any question explicitly scoped to ONE named document — chat retrieval
31
+ cannot be restricted to a single record.
32
+
12
33
  ## Default tool: \`pipeshub_chat\`
13
34
 
14
35
  **Use \`pipeshub_chat\` for any question that could plausibly be answered
15
- by the user's PipesHub-indexed data.** That includes:
36
+ by the user's PipesHub-indexed data** and is not a full-document task:
16
37
 
17
- - Anything about a specific document, file, report, ticket, message, or
18
- page (e.g. "what's in the Q4 sales report?", "summarize the langchain
19
- doc", "what did Aashil say about onboarding?").
38
+ - A question that may span several documents, or where you don't yet
39
+ know which record holds the answer (e.g. "what did Aashil say about
40
+ onboarding?").
20
41
  - Anything about company / org policies, processes, decisions, or
21
42
  history (e.g. "what's our vacation policy?", "who owns the auth
22
43
  service?").
@@ -32,15 +53,17 @@ the user can verify.
32
53
 
33
54
  ## When to use the other tools
34
55
 
35
- - \`pipeshub_search\` — only when the user asks specifically to **find /
36
- locate** a document by name or topic (so you can hand them a list, or
37
- resolve a filename to a \`recordId\` for download). For "what does
38
- the doc say?" use \`pipeshub_chat\` instead — it does the retrieval
39
- internally.
56
+ - \`pipeshub_search\` — locate a document by name or topic and resolve it
57
+ to a \`recordId\`. To read or summarize one specific document, search,
58
+ then pass the top hit's \`recordId\` to \`pipeshub_get_record_content\`.
40
59
  - \`pipeshub_download_record\` — when the user wants the actual file
41
60
  bytes (download, attach, open). Get the \`recordId\` either from
42
61
  citations on a prior \`pipeshub_chat\` response or from
43
62
  \`pipeshub_search\`.
63
+ - \`pipeshub_get_record_content\` — when you need a record's full parsed
64
+ content (returned as a single \`content\` string: metadata header plus
65
+ the document's text) without downloading the original file. Prefer this
66
+ over download when the question is about what the record says.
44
67
  - \`pipeshub_directory\` — people, groups, teams, and \`whoami\` lookups.
45
68
  Not for documents.
46
69
  - \`pipeshub_sources\` — call once at the start of a session to discover
@@ -48,6 +71,36 @@ the user can verify.
48
71
  - \`pipeshub_agents\` — list the org's configured **agents** (specialized
49
72
  assistants with their own prompt, tools, and knowledge scope).
50
73
 
74
+ ## Knowledge Sources
75
+
76
+ A source (an app connector or a KB) holds record groups (a project, space,
77
+ drive, folder), which hold records (one ticket, page, file). Records nest —
78
+ an epic over its stories, a page over its child pages.
79
+
80
+ **Every tool returns metadata or content.**
81
+
82
+ - \`pipeshub_search\` / \`pipeshub_chat\` — content, but only a ranked sample of
83
+ blocks: never all blocks of a record, never every record that matches, never
84
+ a complete list of what exists.
85
+ - \`mode:"lookup"\` and \`mode:"navigate"\` — metadata: recordIds, fields, and
86
+ structure. No document text. \`mode:"content"\` — one whole record.
87
+
88
+ **Combining them.** Search finds a starting point by wording; navigate on a
89
+ \`recordId\` reveals what search structurally cannot — siblings, children,
90
+ linked records, and the group's total count. For a question that must be
91
+ exhaustive ("all", "how many", "every"), do NOT count search hits or trust a
92
+ chat answer: navigate the record group, which reports the real total. For a
93
+ record's siblings, navigate its parent — take the id from the \`Path\` line.
94
+
95
+ **A named record** — an issue key, a Confluence / Drive / Slack link, an
96
+ external ID → \`mode:"lookup"\` FIRST. It returns that record's metadata (for a
97
+ ticket: status, assignee, priority, dates) plus its \`recordId\`, which often
98
+ answers the question outright. Never guess a \`recordId\`.
99
+
100
+ Pass a source's id from \`pipeshub_sources\` as \`apps: ["<id>"]\` to search or
101
+ chat, and as \`nodeId\` to navigate. Navigate and lookup end in a \`Next:\` line
102
+ naming the exact follow-up call — follow it.
103
+
51
104
  ## Agents
52
105
 
53
106
  Some orgs configure **agents** for specific jobs (e.g. a Slack messenger, a
@@ -2,6 +2,7 @@ import { McpServer } from "@modelcontextprotocol/sdk/server/mcp.js";
2
2
 
3
3
  import { PIPESHUB_INSTRUCTIONS } from "./instructions.js";
4
4
  import { PipeshubCore } from "../core.js";
5
+ import { bindNewRequestId } from "../hooks/request-context.js";
5
6
  import { SDKOptions } from "../lib/config.js";
6
7
  import type { ConsoleLogger } from "./console-logger.js";
7
8
  import { createRegisterPrompt } from "./prompts.js";
@@ -14,6 +15,7 @@ import { createRegisterTool, registerDynamicTools } from "./tools.js";
14
15
  import { tool$pipeshubChat } from "./tools/pipeshubChat.js";
15
16
  import { tool$pipeshubSearch } from "./tools/pipeshubSearch.js";
16
17
  import { tool$pipeshubDownloadRecord } from "./tools/pipeshubDownloadRecord.js";
18
+ import { tool$pipeshubGetRecordContent } from "./tools/pipeshubGetRecordContent.js";
17
19
  import { tool$pipeshubDirectory } from "./tools/pipeshubDirectory.js";
18
20
  import { tool$pipeshubSources } from "./tools/pipeshubSources.js";
19
21
  import { tool$pipeshubAgents } from "./tools/pipeshubAgents.js";
@@ -40,7 +42,7 @@ export function createMCPServer(deps: {
40
42
  },
41
43
  );
42
44
 
43
- const getClient = deps.getSDK || (() =>
45
+ const resolveClient = deps.getSDK || (() =>
44
46
  new PipeshubCore({
45
47
  security: deps.security,
46
48
  serverURL: deps.serverURL,
@@ -55,6 +57,11 @@ export function createMCPServer(deps: {
55
57
  : undefined,
56
58
  }));
57
59
 
60
+ const getClient = () => {
61
+ bindNewRequestId();
62
+ return resolveClient();
63
+ };
64
+
58
65
  const scopes = new Set(deps.scopes);
59
66
 
60
67
  const allowedTools = deps.allowedTools && new Set(deps.allowedTools);
@@ -87,8 +94,9 @@ export function createMCPServer(deps: {
87
94
  tool(tool$pipeshubChat); // 2. ask questions (start + continue)
88
95
  tool(tool$pipeshubSearch); // 3. resolve filename → recordId
89
96
  tool(tool$pipeshubDownloadRecord); // 4. fetch a document by id
90
- tool(tool$pipeshubDirectory); // 5. people / groups / teams / whoami
91
- tool(tool$pipeshubAgents); // 6. discover org agents
97
+ tool(tool$pipeshubGetRecordContent); // 5. fetch a record's parsed content
98
+ tool(tool$pipeshubDirectory); // 6. people / groups / teams / whoami
99
+ tool(tool$pipeshubAgents); // 7. discover org agents
92
100
 
93
101
  // Curated prompt: user-invokable tool-routing guidance.
94
102
  prompt(prompt$pipeshubAssistant);
@@ -1,8 +1,14 @@
1
1
  /*
2
2
  * Code generated by Speakeasy (https://speakeasy.com). DO NOT EDIT.
3
+ *
4
+ * Exception: `bytesToBase64` is imported from the hand-maintained
5
+ * `../lib/bytes-to-base64.js` (see that file and `.genignore`). Do not
6
+ * restore the generated one-liner — it overflows the stack on binary
7
+ * payloads.
3
8
  */
4
9
 
5
10
  import * as z from "zod";
11
+ import { bytesToBase64 } from "../lib/bytes-to-base64.js";
6
12
 
7
13
  type BinaryData =
8
14
  | Uint8Array
@@ -14,9 +20,7 @@ type BinaryData =
14
20
 
15
21
  const base64Schema = z.string().base64();
16
22
 
17
- export function bytesToBase64(u8arr: Uint8Array): string {
18
- return btoa(String.fromCodePoint(...u8arr));
19
- }
23
+ export { bytesToBase64 };
20
24
 
21
25
  export async function consumeStream(
22
26
  stream: ReadableStream<Uint8Array>,
@@ -0,0 +1,138 @@
1
+ // AG-UI frame folding for the chat stream.
2
+ //
3
+ // AG-UI is the only wire protocol the conversation and agent stream routes
4
+ // speak; the legacy `connected` / `answer_chunk` / `complete` / `error`
5
+ // vocabulary was removed with the new agent loop and is never emitted.
6
+ //
7
+ // Kept separate from `pipeshubChat.ts` so the fold is a pure function over
8
+ // frames and can be exercised without a live stream.
9
+
10
+ import type { SSEFrame } from "./_helpers.js";
11
+
12
+ export const AGUIEvent = {
13
+ RUN_FINISHED: "RUN_FINISHED",
14
+ RUN_ERROR: "RUN_ERROR",
15
+ CUSTOM: "CUSTOM",
16
+ TEXT_MESSAGE_CONTENT: "TEXT_MESSAGE_CONTENT",
17
+ } as const;
18
+
19
+ export type ChatStreamState = {
20
+ /** Persisted conversation from the terminal frame; null until then. */
21
+ conversation: any | null;
22
+ recordsUsed: number | undefined;
23
+ error: string | null;
24
+ /** From `CUSTOM{name:"conversation_created"}`, available before the answer. */
25
+ conversationId: string | null;
26
+ title: string | null;
27
+ /**
28
+ * Answer text accumulated per `messageId`. Universal `agent` mode forwards
29
+ * child-run text alongside the root answer, so a single buffer would splice
30
+ * sub-agent output into the middle of the reply.
31
+ */
32
+ text: Map<string, string>;
33
+ /** messageIds seen on frames with no `parentRunId` — i.e. the root run. */
34
+ rootMessageIds: Set<string>;
35
+ };
36
+
37
+ export function newChatStreamState(): ChatStreamState {
38
+ return {
39
+ conversation: null,
40
+ recordsUsed: undefined,
41
+ error: null,
42
+ conversationId: null,
43
+ title: null,
44
+ text: new Map(),
45
+ rootMessageIds: new Set(),
46
+ };
47
+ }
48
+
49
+ /**
50
+ * Fold one frame into `state`. Returns true when the frame is terminal and the
51
+ * caller should stop draining the stream.
52
+ */
53
+ /** The subset of AG-UI payload fields this fold reads. */
54
+ type FrameData = {
55
+ result?: {
56
+ conversation?: unknown;
57
+ recordsUsed?: number;
58
+ meta?: { recordsUsed?: number };
59
+ };
60
+ message?: string;
61
+ error?: string;
62
+ name?: string;
63
+ value?: { conversationId?: string; title?: string };
64
+ delta?: string;
65
+ messageId?: string;
66
+ parentRunId?: string | null;
67
+ };
68
+
69
+ export function applyAGUIFrame(
70
+ state: ChatStreamState,
71
+ frame: SSEFrame,
72
+ ): boolean {
73
+ const data = frame.data;
74
+ const d: FrameData = data && typeof data === "object"
75
+ ? data as FrameData
76
+ : {};
77
+
78
+ switch (frame.event) {
79
+ case AGUIEvent.RUN_FINISHED: {
80
+ // Only the gateway's own re-emitted frame carries `result` — it swallows
81
+ // the AI backend's root RUN_FINISHED and re-sends an enriched one after
82
+ // persisting. Sub-agent RUN_FINISHED frames are forwarded verbatim with
83
+ // no `result`, so keying off the event name alone would end the stream
84
+ // as soon as the first sub-agent finished.
85
+ const result = d.result;
86
+ if (!result || typeof result !== "object") return false;
87
+ state.conversation = result.conversation ?? null;
88
+ state.recordsUsed = result.recordsUsed ?? result.meta?.recordsUsed;
89
+ return true;
90
+ }
91
+
92
+ case AGUIEvent.RUN_ERROR: {
93
+ state.error = (typeof data === "string" ? data : null)
94
+ ?? d.message ?? d.error ?? frame.raw ?? "Stream error";
95
+ return true;
96
+ }
97
+
98
+ case AGUIEvent.CUSTOM: {
99
+ if (d.name === "conversation_created") {
100
+ const value = d.value ?? {};
101
+ state.conversationId = value.conversationId ?? state.conversationId;
102
+ state.title = value.title ?? state.title;
103
+ }
104
+ return false;
105
+ }
106
+
107
+ case AGUIEvent.TEXT_MESSAGE_CONTENT: {
108
+ if (typeof d.delta !== "string" || d.delta.length === 0) return false;
109
+ const id = typeof d.messageId === "string" ? d.messageId : "";
110
+ state.text.set(id, (state.text.get(id) ?? "") + d.delta);
111
+ if (d.parentRunId == null) state.rootMessageIds.add(id);
112
+ return false;
113
+ }
114
+
115
+ // RUN_STARTED, STEP_*, TOOL_CALL_*, REASONING_*, STATE_*,
116
+ // TEXT_MESSAGE_START/END, HEARTBEAT — lifecycle noise.
117
+ default:
118
+ return false;
119
+ }
120
+ }
121
+
122
+ /**
123
+ * Best-effort answer when the stream ended without a terminal frame.
124
+ *
125
+ * Prefers the longest root-run buffer; falls back to the longest buffer of any
126
+ * run so a partial answer still reaches the caller.
127
+ */
128
+ export function salvagedText(state: ChatStreamState): string | null {
129
+ const pick = (ids: Iterable<string>): string | null => {
130
+ let best: string | null = null;
131
+ for (const id of ids) {
132
+ const text = state.text.get(id);
133
+ if (text && (best === null || text.length > best.length)) best = text;
134
+ }
135
+ return best;
136
+ };
137
+ return pick(state.rootMessageIds) ?? pick(state.text.keys());
138
+ }
@@ -224,17 +224,24 @@ export function trimConversation(conv: any) {
224
224
  export function trimCitations(citations: unknown) {
225
225
  if (!Array.isArray(citations)) return [];
226
226
  return citations.map((c: any) => {
227
- const md = c?.metadata ?? {};
227
+ // Two wire shapes exist. Flat: `{ content, chunkIndex, metadata }`.
228
+ // Nested (current backend): `{ citationId, citationData: { content,
229
+ // chunkIndex, metadata } }`. Reading only the flat one silently yields
230
+ // a citation with every field `undefined` — which JSON.stringify drops,
231
+ // leaving `{"snippet": null}` and no way to identify the source at all.
232
+ const cd = c?.citationData ?? c;
233
+ const md = cd?.metadata ?? c?.metadata ?? {};
234
+ const content = typeof cd?.content === "string" ? cd.content : c?.content;
228
235
  return {
229
- recordId: md.recordId ?? c?.recordId,
230
- recordName: md.recordName ?? c?.recordName,
231
- snippet: typeof c?.content === "string"
232
- ? c.content.slice(0, 280)
236
+ recordId: md.recordId ?? cd?.recordId ?? c?.recordId,
237
+ recordName: md.recordName ?? cd?.recordName ?? c?.recordName,
238
+ snippet: typeof content === "string"
239
+ ? content.slice(0, 280)
233
240
  : md.blockText ?? null,
234
241
  mimeType: md.mimeType,
235
242
  webUrl: md.webUrl,
236
243
  pageNum: md.pageNum,
237
- chunkIndex: c?.chunkIndex ?? md.chunkIndex,
244
+ chunkIndex: cd?.chunkIndex ?? c?.chunkIndex ?? md.chunkIndex,
238
245
  };
239
246
  });
240
247
  }