@yadsh/dsh-qa-surface 0.10.0 → 0.11.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 (169) hide show
  1. package/README.md +251 -12
  2. package/lib/access/service.js +49 -10
  3. package/lib/access/service.js.map +1 -1
  4. package/lib/account-remotes.js +41 -0
  5. package/lib/account-remotes.js.map +1 -1
  6. package/lib/accounts/database.js +140 -0
  7. package/lib/accounts/database.js.map +1 -1
  8. package/lib/accounts/errors.js.map +1 -1
  9. package/lib/accounts/service-token.js +103 -0
  10. package/lib/accounts/service-token.js.map +1 -0
  11. package/lib/accounts/store.js +291 -5
  12. package/lib/accounts/store.js.map +1 -1
  13. package/lib/admin/conversation-log.js +14 -8
  14. package/lib/admin/conversation-log.js.map +1 -1
  15. package/lib/admin/permissions.js +5 -0
  16. package/lib/admin/permissions.js.map +1 -1
  17. package/lib/admin/quality-store.js +4 -0
  18. package/lib/admin/quality-store.js.map +1 -1
  19. package/lib/admin/service.js +173 -0
  20. package/lib/admin/service.js.map +1 -1
  21. package/lib/admin/session-log.js +39 -8
  22. package/lib/admin/session-log.js.map +1 -1
  23. package/lib/attachment-rules.js +39 -6
  24. package/lib/attachment-rules.js.map +1 -1
  25. package/lib/cli.js +83 -1
  26. package/lib/cli.js.map +1 -1
  27. package/lib/client/QaAccountsController.js +213 -4
  28. package/lib/client/QaAccountsController.js.map +1 -1
  29. package/lib/client/QaSessionController.js +37 -21
  30. package/lib/client/QaSessionController.js.map +1 -1
  31. package/lib/client/QaSurface.js +28 -4
  32. package/lib/client/QaSurface.js.map +1 -1
  33. package/lib/client/admin/QaAdmin.js +9 -1
  34. package/lib/client/admin/QaAdmin.js.map +1 -1
  35. package/lib/client/admin/pages/SkillFiles.js +78 -0
  36. package/lib/client/admin/pages/SkillFiles.js.map +1 -0
  37. package/lib/client/admin/pages/Users.js +65 -1
  38. package/lib/client/admin/pages/Users.js.map +1 -1
  39. package/lib/client/admin/routes.js +8 -1
  40. package/lib/client/admin/routes.js.map +1 -1
  41. package/lib/client/admin/styles.js +2 -0
  42. package/lib/client/admin/styles.js.map +1 -1
  43. package/lib/client/attachments.js +41 -11
  44. package/lib/client/attachments.js.map +1 -1
  45. package/lib/client/components/QaAuthGate.js +19 -10
  46. package/lib/client/components/QaAuthGate.js.map +1 -1
  47. package/lib/client/components/QaChangelog.js +27 -0
  48. package/lib/client/components/QaChangelog.js.map +1 -1
  49. package/lib/client/index.js +13 -0
  50. package/lib/client/index.js.map +1 -1
  51. package/lib/client/lineage.js +64 -0
  52. package/lib/client/lineage.js.map +1 -1
  53. package/lib/client/settings/sections/attachments.js +6 -5
  54. package/lib/client/settings/sections/attachments.js.map +1 -1
  55. package/lib/client/settings/sections/notes.js +22 -4
  56. package/lib/client/settings/sections/notes.js.map +1 -1
  57. package/lib/client/styles.js +9 -0
  58. package/lib/client/styles.js.map +1 -1
  59. package/lib/client/types.js.map +1 -1
  60. package/lib/client/user-settings/IntegrationTokensPage.js +150 -0
  61. package/lib/client/user-settings/IntegrationTokensPage.js.map +1 -0
  62. package/lib/client/user-settings/PasswordSettingsPage.js +48 -0
  63. package/lib/client/user-settings/PasswordSettingsPage.js.map +1 -0
  64. package/lib/client/user-settings/SkillCatalog.js +2 -2
  65. package/lib/client/user-settings/SkillCatalog.js.map +1 -1
  66. package/lib/client/user-settings/UserSettingsDialog.js +17 -2
  67. package/lib/client/user-settings/UserSettingsDialog.js.map +1 -1
  68. package/lib/client/user-settings/copy.js +20 -0
  69. package/lib/client/user-settings/copy.js.map +1 -1
  70. package/lib/client.js +2466 -137
  71. package/lib/client.js.map +1 -1
  72. package/lib/config-resolvers/attachments.js +6 -5
  73. package/lib/config-resolvers/attachments.js.map +1 -1
  74. package/lib/config-resolvers/defaults.js +23 -2
  75. package/lib/config-resolvers/defaults.js.map +1 -1
  76. package/lib/config-resolvers/integration.js +94 -0
  77. package/lib/config-resolvers/integration.js.map +1 -0
  78. package/lib/config-resolvers/notes.js +2 -0
  79. package/lib/config-resolvers/notes.js.map +1 -1
  80. package/lib/config.js +72 -0
  81. package/lib/config.js.map +1 -1
  82. package/lib/index.js +197 -2
  83. package/lib/index.js.map +1 -1
  84. package/lib/integration/answer.js +100 -0
  85. package/lib/integration/answer.js.map +1 -0
  86. package/lib/integration/attachments.js +231 -0
  87. package/lib/integration/attachments.js.map +1 -0
  88. package/lib/integration/contract.js +53 -0
  89. package/lib/integration/contract.js.map +1 -0
  90. package/lib/integration/host-runner.js +243 -0
  91. package/lib/integration/host-runner.js.map +1 -0
  92. package/lib/integration/http.js +538 -0
  93. package/lib/integration/http.js.map +1 -0
  94. package/lib/integration/service.js +331 -0
  95. package/lib/integration/service.js.map +1 -0
  96. package/lib/integration/transcript-reader.js +202 -0
  97. package/lib/integration/transcript-reader.js.map +1 -0
  98. package/lib/integration/transcript.js +148 -0
  99. package/lib/integration/transcript.js.map +1 -0
  100. package/lib/personal-skills/index.js +2 -2
  101. package/lib/personal-skills/index.js.map +1 -1
  102. package/lib/personal-skills/paths.js +14 -8
  103. package/lib/personal-skills/paths.js.map +1 -1
  104. package/lib/personal-skills/provider.js +16 -4
  105. package/lib/personal-skills/provider.js.map +1 -1
  106. package/lib/personal-skills/service.js +240 -30
  107. package/lib/personal-skills/service.js.map +1 -1
  108. package/lib/prompt-notes.js +58 -0
  109. package/lib/prompt-notes.js.map +1 -1
  110. package/lib/provenance/document-preview.js +10 -1
  111. package/lib/provenance/document-preview.js.map +1 -1
  112. package/lib/qa-tools/catalog.js +12 -1
  113. package/lib/qa-tools/catalog.js.map +1 -1
  114. package/lib/qa-tools/docs-tools.js +563 -0
  115. package/lib/qa-tools/docs-tools.js.map +1 -0
  116. package/lib/resolve-config.js +4 -0
  117. package/lib/resolve-config.js.map +1 -1
  118. package/lib/secure-session.js +83 -3
  119. package/lib/secure-session.js.map +1 -1
  120. package/lib/shared/integration-tokens.js +23 -0
  121. package/lib/shared/integration-tokens.js.map +1 -0
  122. package/lib/typert.host.js +1176 -213
  123. package/lib/typert.remote-client.d.ts +27 -1
  124. package/lib/typert.remote-client.js +911 -69
  125. package/lib/types/access/service.d.ts +33 -2
  126. package/lib/types/account-remotes.d.ts +30 -1
  127. package/lib/types/accounts/database.d.ts +53 -0
  128. package/lib/types/accounts/errors.d.ts +5 -1
  129. package/lib/types/accounts/service-token.d.ts +70 -0
  130. package/lib/types/accounts/store.d.ts +131 -1
  131. package/lib/types/admin/conversation-log.d.ts +12 -0
  132. package/lib/types/admin/service.d.ts +56 -1
  133. package/lib/types/admin/session-log.d.ts +21 -0
  134. package/lib/types/attachment-rules.d.ts +26 -3
  135. package/lib/types/client/QaAccountsController.d.ts +64 -5
  136. package/lib/types/client/QaSessionController.d.ts +24 -2
  137. package/lib/types/client/admin/pages/SkillFiles.d.ts +17 -0
  138. package/lib/types/client/admin/routes.d.ts +2 -0
  139. package/lib/types/client/attachments.d.ts +21 -4
  140. package/lib/types/client/lineage.d.ts +42 -0
  141. package/lib/types/client/settings/sections/notes.d.ts +5 -3
  142. package/lib/types/client/types.d.ts +71 -1
  143. package/lib/types/client/user-settings/IntegrationTokensPage.d.ts +21 -0
  144. package/lib/types/client/user-settings/PasswordSettingsPage.d.ts +19 -0
  145. package/lib/types/client/user-settings/UserSettingsDialog.d.ts +11 -2
  146. package/lib/types/client/user-settings/copy.d.ts +10 -1
  147. package/lib/types/config-resolvers/integration.d.ts +47 -0
  148. package/lib/types/config.d.ts +10 -0
  149. package/lib/types/index.d.ts +73 -1
  150. package/lib/types/integration/answer.d.ts +65 -0
  151. package/lib/types/integration/attachments.d.ts +85 -0
  152. package/lib/types/integration/contract.d.ts +196 -0
  153. package/lib/types/integration/host-runner.d.ts +44 -0
  154. package/lib/types/integration/http.d.ts +79 -0
  155. package/lib/types/integration/service.d.ts +120 -0
  156. package/lib/types/integration/transcript-reader.d.ts +100 -0
  157. package/lib/types/integration/transcript.d.ts +59 -0
  158. package/lib/types/personal-skills/index.d.ts +2 -2
  159. package/lib/types/personal-skills/paths.d.ts +15 -3
  160. package/lib/types/personal-skills/provider.d.ts +14 -2
  161. package/lib/types/personal-skills/service.d.ts +89 -14
  162. package/lib/types/prompt-notes.d.ts +28 -0
  163. package/lib/types/provenance/document-preview.d.ts +4 -0
  164. package/lib/types/qa-tools/catalog.d.ts +1 -1
  165. package/lib/types/qa-tools/docs-tools.d.ts +160 -0
  166. package/lib/types/secure-session.d.ts +52 -1
  167. package/lib/types/shared/integration-tokens.d.ts +24 -0
  168. package/lib/types/types.d.ts +170 -2
  169. package/package.json +4 -4
@@ -0,0 +1,100 @@
1
+ import { messageContentText } from "../admin/conversation-log.js";
2
+ /** The empty answer: what a turn that committed nothing readable looks like. */
3
+ export const QA_EMPTY_ANSWER = Object.freeze({
4
+ answer: "",
5
+ interrupted: false,
6
+ seq: null,
7
+ });
8
+ function record(value) {
9
+ return typeof value === "object" && value !== null && !Array.isArray(value)
10
+ ? value
11
+ : undefined;
12
+ }
13
+ /**
14
+ * Project the answer of the turn that starts after `afterSeq`.
15
+ *
16
+ * Only events after the caller's own prompt are read: a chat is continued
17
+ * across questions, and picking the last assistant message of the whole log
18
+ * would hand question #2 the answer to question #1 whenever the new turn
19
+ * committed nothing.
20
+ *
21
+ * @param events - durable events, in any order.
22
+ * @param afterSeq - the seq of the prompt whose answer is wanted.
23
+ * @returns the publishable answer and whether the turn was interrupted.
24
+ */
25
+ export function answerAfter(events, afterSeq) {
26
+ const ordered = [...events].sort((left, right) => left.seq - right.seq);
27
+ let answer = "";
28
+ let seq = null;
29
+ let lastWasInterrupted = false;
30
+ for (const event of ordered) {
31
+ if (event.type !== "assistant/message" || event.seq <= afterSeq)
32
+ continue;
33
+ const data = record(event.data);
34
+ const message = record(data?.message);
35
+ lastWasInterrupted = data?.interrupted === true;
36
+ const text = messageContentText(message?.content).trim();
37
+ if (text === "")
38
+ continue;
39
+ answer = text;
40
+ seq = event.seq;
41
+ }
42
+ return Object.freeze({ answer, interrupted: lastWasInterrupted, seq });
43
+ }
44
+ /**
45
+ * Cut an answer down to the size the caller publishes.
46
+ *
47
+ * The bridge posts the answer into a ticket comment, which has a size budget
48
+ * of its own: an over-long comment is not rejected there, it is cut — and a cut
49
+ * that lands in the middle of a sentence, or worse inside a code block, reads
50
+ * like a broken answer. The cut therefore prefers the last paragraph break,
51
+ * then the last line, then the last sentence, and only then an arbitrary word
52
+ * boundary, and it appends an ellipsis so the reader sees that text is missing
53
+ * rather than finished. A boundary that would leave less than half the budget
54
+ * used is refused: a single early paragraph break must not turn a full answer
55
+ * into one line.
56
+ * @param answer - the settled answer, already trimmed.
57
+ * @param maxCharacters - the publish budget; a non-positive value never cuts.
58
+ * @returns the answer, or its bounded head.
59
+ */
60
+ export function boundAnswer(answer, maxCharacters) {
61
+ if (maxCharacters <= 0 || answer.length <= maxCharacters)
62
+ return answer;
63
+ // The ellipsis itself is part of the budget, so a cut answer still fits in
64
+ // the limit the operator configured.
65
+ const head = answer.slice(0, maxCharacters - 1);
66
+ const floor = Math.floor(head.length / 2);
67
+ let kept = head;
68
+ for (const separator of ["\n\n", "\n", ". ", " "]) {
69
+ const at = head.lastIndexOf(separator);
70
+ if (at >= floor) {
71
+ kept = head.slice(0, separator === ". " ? at + 1 : at);
72
+ break;
73
+ }
74
+ }
75
+ return `${kept.replace(/\s+$/u, "")}…`;
76
+ }
77
+ /**
78
+ * The seq of the newest user-authored message in a log, which is the floor
79
+ * `answerAfter` reads from when the caller has just submitted a prompt.
80
+ * Synthetic context (injected notes, skill payloads) is skipped: it is model
81
+ * input recorded as a user message, and one arriving after the answer would
82
+ * otherwise hide it.
83
+ * @param events - durable events, in any order.
84
+ * @returns the seq, or 0 when the log holds no human prompt yet.
85
+ */
86
+ export function lastPromptSeq(events) {
87
+ let seq = 0;
88
+ for (const event of events) {
89
+ if (event.type !== "user/message")
90
+ continue;
91
+ const data = record(event.data);
92
+ const source = record(data?.source);
93
+ if (source?.kind !== "user")
94
+ continue;
95
+ if (event.seq > seq)
96
+ seq = event.seq;
97
+ }
98
+ return seq;
99
+ }
100
+ //# sourceMappingURL=answer.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"answer.js","sourceRoot":"","sources":["../../src/integration/answer.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,231 @@
1
+ import { mkdtemp, rm, writeFile } from "node:fs/promises";
2
+ import { tmpdir } from "node:os";
3
+ import path from "node:path";
4
+ import { QA_PDF_MEDIA_TYPE, QA_WORD_MEDIA_TYPE } from "../shared/documents.js";
5
+ import { QaIntegrationAttachmentError, } from "./contract.js";
6
+ /**
7
+ * Bringing a ticket's attachments into the prompt.
8
+ *
9
+ * An image rides the prompt inline, because that is what the harness carries.
10
+ * A file does not: `PromptContentPart` names one as an opaque receipt minted by
11
+ * the harness's own upload service, which an external caller has no session to
12
+ * reach — and that service is what stores the copy the model later reads with
13
+ * its file tools. The bridge's non-image attachments are therefore read *here*
14
+ * and arrive as text: a text file is decoded, a document is extracted through
15
+ * the deployment's own document pipeline (the same runtime, providers and
16
+ * limits the `document_*` tools use), and the result joins the prompt as one
17
+ * more text part.
18
+ *
19
+ * That is a deliberate trade. It costs the fidelity of a format the pipeline
20
+ * cannot read — such a request is refused with the bridge's own fallback signal
21
+ * rather than answered with a question the model cannot see the attachment for.
22
+ * It buys a path that works in every deployment shape: nothing is written into
23
+ * a shared workspace, the staging directory is temporary and removed after the
24
+ * extraction, and no second converter is grown inside this plugin.
25
+ */
26
+ /**
27
+ * Plain-text formats the bridge sends; their bytes *are* the content, so
28
+ * decoding them needs no pipeline and cannot fail on a deployment.
29
+ */
30
+ export const QA_INTEGRATION_TEXT_MEDIA_TYPES = Object.freeze(["text/plain", "text/csv", "text/markdown"]);
31
+ /**
32
+ * Documents the deployment's pipeline is asked to extract. PDF and the OOXML
33
+ * family are what the bridge's own filter lets through; whether a particular
34
+ * one is readable is the pipeline's answer, not a list kept here.
35
+ */
36
+ export const QA_INTEGRATION_DOCUMENT_MEDIA_TYPES = Object.freeze([
37
+ QA_PDF_MEDIA_TYPE,
38
+ QA_WORD_MEDIA_TYPE,
39
+ "application/vnd.openxmlformats-officedocument.spreadsheetml.sheet",
40
+ "application/vnd.openxmlformats-officedocument.presentationml.presentation",
41
+ ]);
42
+ /** Total inlined attachment text per question, across every attachment. */
43
+ export const QA_INTEGRATION_MAX_ATTACHMENT_CHARS = 120_000;
44
+ /** No single attachment may claim the whole budget. */
45
+ export const QA_INTEGRATION_MAX_ATTACHMENT_CHARS_PER_FILE = 60_000;
46
+ /** A file name is a label in the prompt, not a path. */
47
+ export const QA_INTEGRATION_MAX_ATTACHMENT_NAME = 120;
48
+ /** Whether the bytes are the content: decoding needs only UTF-8. */
49
+ export function isIntegrationTextMediaType(mediaType) {
50
+ return QA_INTEGRATION_TEXT_MEDIA_TYPES.includes(mediaType.trim().toLowerCase());
51
+ }
52
+ /** Whether the attachment has to go through the document pipeline. */
53
+ export function isIntegrationDocumentMediaType(mediaType) {
54
+ return QA_INTEGRATION_DOCUMENT_MEDIA_TYPES.includes(mediaType.trim().toLowerCase());
55
+ }
56
+ /**
57
+ * Drop every C0/C1 control character and DEL.
58
+ *
59
+ * Spelled as a code-point filter rather than a character class: a name is a
60
+ * log field and a prompt heading, and a newline smuggled through one of those
61
+ * would let the caller forge lines in either.
62
+ */
63
+ function stripControlCharacters(value) {
64
+ return Array.from(value)
65
+ .filter((character) => {
66
+ const code = character.codePointAt(0) ?? 0;
67
+ return code > 0x1f && code !== 0x7f;
68
+ })
69
+ .join("");
70
+ }
71
+ /**
72
+ * Reduce a caller-supplied file name to the label a prompt can carry.
73
+ *
74
+ * The name arrives from another system and is used as a log field, a heading in
75
+ * the prompt and the file name of one temporary file — so directory parts,
76
+ * traversal, control characters and an unbounded length all have to go before
77
+ * it is any of those. An empty result is named by its media type.
78
+ * @param raw - the name the caller sent.
79
+ * @param mediaType - its media type, for the fallback name.
80
+ * @returns a safe, non-empty label.
81
+ */
82
+ export function safeAttachmentName(raw, mediaType) {
83
+ const base = stripControlCharacters(path.basename(raw.replace(/\\/gu, "/")))
84
+ .replace(/^\.+/u, "")
85
+ .trim();
86
+ const bounded = base.slice(0, QA_INTEGRATION_MAX_ATTACHMENT_NAME);
87
+ if (bounded !== "")
88
+ return bounded;
89
+ const subtype = mediaType.split("/")[1] ?? "file";
90
+ return `attachment.${subtype.replace(/[^a-z0-9.]/giu, "")}`;
91
+ }
92
+ /** A human-readable size for the prompt's heading. */
93
+ function formatBytes(bytes) {
94
+ if (bytes < 1024)
95
+ return `${String(bytes)} Б`;
96
+ if (bytes < 1024 * 1024)
97
+ return `${String(Math.round(bytes / 1024))} КБ`;
98
+ const megabytes = bytes / (1024 * 1024);
99
+ return `${String(Math.round(megabytes * 10) / 10).replace(".", ",")} МБ`;
100
+ }
101
+ /**
102
+ * Whether a text attachment is actually text.
103
+ *
104
+ * A NUL byte in the head of a file is the cheapest reliable signal that the
105
+ * caller labelled something binary as text; decoding it would put replacement
106
+ * characters in the prompt and call it the document's content.
107
+ */
108
+ function looksLikeText(bytes) {
109
+ return !bytes.subarray(0, 8192).includes(0);
110
+ }
111
+ /** Cut extracted text to the budget, on a line or word boundary where possible. */
112
+ function boundText(text, budget) {
113
+ if (text.length <= budget)
114
+ return text;
115
+ const head = text.slice(0, budget);
116
+ const at = Math.max(head.lastIndexOf("\n"), head.lastIndexOf(" "));
117
+ return `${(at > 0 ? head.slice(0, at) : head).trimEnd()}…`;
118
+ }
119
+ /** One attachment as the prompt carries it: a heading and the readable content. */
120
+ function attachmentBlock(input) {
121
+ const heading = `--- ${input.name} (${input.mediaType}, ${formatBytes(input.bytes)}) ---`;
122
+ const note = input.truncated
123
+ ? "\n\n[…текст сокращён, показано начало файла]"
124
+ : "";
125
+ return {
126
+ type: "text",
127
+ text: input.text === ""
128
+ ? `${heading}\n[файл прочитан, но не содержит текста]`
129
+ : `${heading}\n${input.text}${note}`,
130
+ };
131
+ }
132
+ /**
133
+ * Extract one document through the deployment's pipeline.
134
+ *
135
+ * The pipeline takes a path, not bytes, so the attachment is written to a
136
+ * temporary directory created for this one extraction, and that directory is
137
+ * the workspace root the conversion is scoped to. An unconfigured deployment
138
+ * therefore keeps the conversion's artifacts beside the input — inside the
139
+ * directory `finally` removes — while a deployment that configured its own
140
+ * artifact root keeps them under it, where its own retention sweep already
141
+ * governs them. Either way nothing lands in a workspace the deployment shares.
142
+ */
143
+ async function extractDocumentText(file, name, deps) {
144
+ const documents = deps.documents;
145
+ if (documents === undefined) {
146
+ throw new QaIntegrationAttachmentError(name, "unavailable", "this deployment has no document pipeline");
147
+ }
148
+ const directory = await mkdtemp(path.join(tmpdir(), "qa-integration-attachment-"));
149
+ try {
150
+ const input = path.join(directory, name);
151
+ await writeFile(input, file.bytes, { mode: 0o600 });
152
+ const extracted = await documents.toMarkdown({ file: input }, {
153
+ workspaceRoot: directory,
154
+ sessionId: deps.sessionId,
155
+ signal: deps.signal,
156
+ });
157
+ return extracted.markdown;
158
+ }
159
+ catch (error) {
160
+ if (error instanceof QaIntegrationAttachmentError)
161
+ throw error;
162
+ deps.logger.warn("integration.attachment-unreadable", {
163
+ name,
164
+ mediaType: file.mediaType,
165
+ message: error instanceof Error ? error.message : String(error),
166
+ });
167
+ throw new QaIntegrationAttachmentError(name, "unsupported", `the document pipeline could not read ${name}`);
168
+ }
169
+ finally {
170
+ await rm(directory, { recursive: true, force: true }).catch(() => undefined);
171
+ }
172
+ }
173
+ /**
174
+ * Turn the request's file attachments into prompt text parts.
175
+ *
176
+ * The parts are appended after the question, in the order the caller sent them,
177
+ * and the question itself is untouched. A file the Host cannot read refuses the
178
+ * whole request: answering with the question but not its attachments would hand
179
+ * the model a question about material it never received, which is exactly what
180
+ * the bridge's 415 fallback exists to avoid.
181
+ *
182
+ * @param files - the non-image attachments of one request.
183
+ * @param deps - pipeline, session and budgets.
184
+ * @returns one text part per readable attachment, plus a note when the budget cut the list.
185
+ */
186
+ export async function attachmentPromptParts(files, deps) {
187
+ if (files.length === 0)
188
+ return [];
189
+ const total = deps.maxChars ?? QA_INTEGRATION_MAX_ATTACHMENT_CHARS;
190
+ const parts = [];
191
+ let remaining = total;
192
+ let dropped = 0;
193
+ for (const file of files) {
194
+ if (remaining <= 0) {
195
+ dropped += 1;
196
+ continue;
197
+ }
198
+ const mediaType = file.mediaType.trim().toLowerCase();
199
+ const name = safeAttachmentName(file.name, mediaType);
200
+ let text;
201
+ if (isIntegrationTextMediaType(mediaType)) {
202
+ if (!looksLikeText(file.bytes)) {
203
+ throw new QaIntegrationAttachmentError(name, "unsupported", `${name} is not text`);
204
+ }
205
+ text = file.bytes.toString("utf8");
206
+ }
207
+ else {
208
+ text = await extractDocumentText(file, name, deps);
209
+ }
210
+ const budget = Math.min(remaining, QA_INTEGRATION_MAX_ATTACHMENT_CHARS_PER_FILE);
211
+ const bounded = boundText(text.trim(), budget);
212
+ remaining -= bounded.length;
213
+ parts.push(Object.freeze(attachmentBlock({
214
+ name,
215
+ mediaType,
216
+ bytes: file.bytes.length,
217
+ text: bounded,
218
+ truncated: bounded.length < text.trim().length,
219
+ })));
220
+ }
221
+ if (dropped > 0) {
222
+ parts.push({
223
+ type: "text",
224
+ text: `[${String(dropped)} вложений не показано: исчерпан предел текста вложений]`,
225
+ });
226
+ }
227
+ return Object.freeze(parts);
228
+ }
229
+ /** Re-exported so the runner does not reach into the contract for one name. */
230
+ export { QaIntegrationAttachmentError };
231
+ //# sourceMappingURL=attachments.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"attachments.js","sourceRoot":"","sources":["../../src/integration/attachments.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,53 @@
1
+ /**
2
+ * The integration API's wire contract: what an external application may post to
3
+ * `/qa/api/ask`, what it gets back, and the refusal vocabulary the HTTP layer
4
+ * maps onto status codes.
5
+ *
6
+ * The shape follows the ticket bridge specification the deployment was asked to
7
+ * implement (issue #177): a Markdown `answer`, the `sources` that back it, a
8
+ * `confidence` bucket, an `escalate` flag for "the assistant could not answer,
9
+ * send it to a specialist", and a `reason` for the logs. `chat_id` is the DSH
10
+ * session identity: sending it back as `session_id` continues the same
11
+ * conversation, which is the whole point of the bridge keeping it.
12
+ */
13
+ /**
14
+ * HTTP status per refusal. The mapping is part of the contract, not a detail:
15
+ * the bridge retries on 5xx and 429, falls back to a bodyless JSON request on
16
+ * 415, and treats 401/403 as a configuration error it must not spin on.
17
+ */
18
+ export const QA_INTEGRATION_STATUS = Object.freeze({
19
+ unauthorized: 401,
20
+ forbidden: 403,
21
+ "not-found": 404,
22
+ "invalid-request": 400,
23
+ "unsupported-media": 415,
24
+ "payload-too-large": 413,
25
+ "rate-limited": 429,
26
+ busy: 429,
27
+ unavailable: 503,
28
+ timeout: 504,
29
+ });
30
+ /** One refusal, carrying the status the HTTP layer answers with. */
31
+ export class QaIntegrationError extends Error {
32
+ reason;
33
+ constructor(reason, message, options) {
34
+ super(message, options);
35
+ this.name = "QaIntegrationError";
36
+ this.reason = reason;
37
+ }
38
+ get status() {
39
+ return QA_INTEGRATION_STATUS[this.reason];
40
+ }
41
+ }
42
+ /** One attachment the Host could not turn into prompt content. */
43
+ export class QaIntegrationAttachmentError extends Error {
44
+ attachment;
45
+ reason;
46
+ constructor(attachment, reason, message) {
47
+ super(message);
48
+ this.attachment = attachment;
49
+ this.name = "QaIntegrationAttachmentError";
50
+ this.reason = reason;
51
+ }
52
+ }
53
+ //# sourceMappingURL=contract.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"contract.js","sourceRoot":"","sources":["../../src/integration/contract.ts"],"names":[],"mappings":"AAAA;;;;;;;;;;;GAWG;AAiBH;;;;GAIG;AACH,MAAM,CAAC,MAAM,qBAAqB,GAE9B,MAAM,CAAC,MAAM,CAAC;IAChB,YAAY,EAAE,GAAG;IACjB,SAAS,EAAE,GAAG;IACd,WAAW,EAAE,GAAG;IAChB,iBAAiB,EAAE,GAAG;IACtB,mBAAmB,EAAE,GAAG;IACxB,mBAAmB,EAAE,GAAG;IACxB,cAAc,EAAE,GAAG;IACnB,IAAI,EAAE,GAAG;IACT,WAAW,EAAE,GAAG;IAChB,OAAO,EAAE,GAAG;CACb,CAAC,CAAC;AAEH,oEAAoE;AACpE,MAAM,OAAO,kBAAmB,SAAQ,KAAK;IAClC,MAAM,CAAsB;IAErC,YACE,MAA2B,EAC3B,OAAe,EACf,OAAsC;QAEtC,KAAK,CAAC,OAAO,EAAE,OAAO,CAAC,CAAC;QACxB,IAAI,CAAC,IAAI,GAAG,oBAAoB,CAAC;QACjC,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;IAED,IAAI,MAAM;QACR,OAAO,qBAAqB,CAAC,IAAI,CAAC,MAAM,CAAC,CAAC;IAC5C,CAAC;CACF;AA6CD,kEAAkE;AAClE,MAAM,OAAO,4BAA6B,SAAQ,KAAK;IAI1C,UAAU;IAHZ,MAAM,CAAgC;IAE/C,YACW,UAAkB,EAC3B,MAAqC,EACrC,OAAe;QAEf,KAAK,CAAC,OAAO,CAAC,CAAC;0BAJN,UAAU;QAKnB,IAAI,CAAC,IAAI,GAAG,8BAA8B,CAAC;QAC3C,IAAI,CAAC,MAAM,GAAG,MAAM,CAAC;IACvB,CAAC;CACF"}
@@ -0,0 +1,243 @@
1
+ import { randomUUID } from "node:crypto";
2
+ import { SessionId } from "@deepseek-ai/dsh-session/types";
3
+ import { WorkspaceId } from "@deepseek-ai/dsh-workspace";
4
+ import { prepareQaUserWorkspace } from "../user-workspace.js";
5
+ import { answerAfter, lastPromptSeq } from "./answer.js";
6
+ import { attachmentPromptParts } from "./attachments.js";
7
+ import { QaIntegrationTranscriptReader } from "./transcript-reader.js";
8
+ /** Build the runner for one plugin instance. */
9
+ export function createQaIntegrationRunner(options) {
10
+ const { ctx, logger } = options;
11
+ // One reader for the plugin's lifetime: its whole value is the windows it
12
+ // keeps between calls, so a per-request instance would be a slower copy of
13
+ // the plain projection.
14
+ const transcripts = new QaIntegrationTranscriptReader({
15
+ log: options.sessionLog,
16
+ logger,
17
+ });
18
+ const requireAccounts = () => {
19
+ const accounts = options.accounts();
20
+ if (accounts === undefined) {
21
+ throw new Error("QA accounts are not enabled on this deployment.");
22
+ }
23
+ return accounts;
24
+ };
25
+ const openChat = async (input) => {
26
+ const config = options.getConfig();
27
+ const accounts = requireAccounts();
28
+ const owner = accounts.accountById(input.userId);
29
+ if (owner === undefined) {
30
+ throw new Error("the integration token's account no longer exists");
31
+ }
32
+ const id = SessionId(`session-${randomUUID()}`);
33
+ // The ownership record is written before the session exists, exactly as
34
+ // the browser path does it: the id is minted here, so no other caller can
35
+ // claim it in between, and a failure below rolls the record back.
36
+ options.access.reserveSessionForUser(owner, String(id));
37
+ try {
38
+ options.admission.preflightDeployment();
39
+ let userCwd;
40
+ if (config.accounts.perUserWorkspace) {
41
+ const workspaceId = config.session.workspaceId;
42
+ if (workspaceId === null) {
43
+ throw new Error("Per-user QA workspace configuration is unavailable.");
44
+ }
45
+ const workspace = ctx.workspaceRegistry.get(WorkspaceId(workspaceId));
46
+ if (workspace === undefined) {
47
+ throw new Error(`Configured QA workspace ${workspaceId} is unavailable.`);
48
+ }
49
+ userCwd = prepareQaUserWorkspace(workspace.path, owner.id);
50
+ }
51
+ const created = await ctx.sessionController.create({
52
+ sessionId: id,
53
+ ...(userCwd !== undefined
54
+ ? { cwd: userCwd }
55
+ : config.session.workspaceId !== null
56
+ ? { workspaceId: WorkspaceId(config.session.workspaceId) }
57
+ : config.session.cwd !== null
58
+ ? { cwd: config.session.cwd }
59
+ : {}),
60
+ ...(config.session.agentPreset === null
61
+ ? {}
62
+ : { agentPreset: config.session.agentPreset }),
63
+ });
64
+ if (config.session.provider !== null && config.session.model !== null) {
65
+ await ctx.sessionController.selectModel({
66
+ sessionId: created.sessionId,
67
+ provider: config.session.provider,
68
+ model: config.session.model,
69
+ ...(config.session.reasoningEffort === null
70
+ ? {}
71
+ : { reasoningEffort: config.session.reasoningEffort }),
72
+ });
73
+ }
74
+ const sessionId = String(created.sessionId);
75
+ await options.admission.secureSessionForUser(owner.id, sessionId);
76
+ logger.info("integration.chat-opened", {
77
+ sessionId,
78
+ userId: owner.id,
79
+ ticketKey: input.ticketKey,
80
+ });
81
+ return sessionId;
82
+ }
83
+ catch (error) {
84
+ // Nothing else would ever remove the record: the chat was never
85
+ // returned to the caller, so no later call can name it.
86
+ accounts.releaseSessionReservation(owner.id, String(id));
87
+ throw error;
88
+ }
89
+ };
90
+ /** The newest durable cursor in a log, or 0 when there is nothing to read. */
91
+ const floorOf = async (sessionId) => {
92
+ const read = await options.sessionLog.read(sessionId);
93
+ if (!read.ok)
94
+ return 0;
95
+ return read.events.reduce((max, event) => Math.max(max, event.seq), 0);
96
+ };
97
+ /**
98
+ * The prompt of one question: the question, then the attachments.
99
+ *
100
+ * Images keep the shape the harness carries them in. Files are read on the
101
+ * Host — a text file decoded, a document extracted through the deployment's
102
+ * pipeline — and arrive as text under a heading per file, preceded by one
103
+ * lead line so the model knows why the question has more than it asked for.
104
+ * A file that cannot be read throws, which the service turns into the 415
105
+ * the caller's fallback understands: publishing an answer to a question the
106
+ * model could not see the attachment for is worse than asking again without
107
+ * it.
108
+ */
109
+ const promptParts = async (message, attachments, sessionId, signal) => {
110
+ const images = attachments
111
+ .filter((attachment) => attachment.kind === "image")
112
+ .map((attachment) => ({
113
+ type: "image",
114
+ mediaType: attachment.mediaType,
115
+ data: attachment.data,
116
+ ...(attachment.name === undefined ? {} : { name: attachment.name }),
117
+ }));
118
+ const files = attachments.filter((attachment) => attachment.kind === "file");
119
+ if (files.length === 0) {
120
+ return [{ type: "text", text: message }, ...images];
121
+ }
122
+ const extracted = await attachmentPromptParts(files, {
123
+ documents: options.documents(),
124
+ sessionId,
125
+ signal,
126
+ logger,
127
+ });
128
+ return [
129
+ { type: "text", text: message },
130
+ ...images,
131
+ { type: "text", text: QA_ATTACHMENT_LEAD },
132
+ ...extracted,
133
+ ];
134
+ };
135
+ return {
136
+ async run(input) {
137
+ const chatId = input.chatId === null
138
+ ? await openChat({
139
+ userId: input.userId,
140
+ ticketKey: input.ticketKey,
141
+ })
142
+ : input.chatId;
143
+ input.onChat?.(chatId);
144
+ if (input.chatId !== null) {
145
+ // A continued chat is re-attested before every question. The same call
146
+ // the browser makes when it opens a session: it is what refuses a
147
+ // chat belonging to another account (the ownership record is the
148
+ // authority) and what keeps the tool policy applied to a session
149
+ // resumed by a process that has never seen it.
150
+ await options.admission.secureSessionForUser(input.userId, chatId);
151
+ }
152
+ const floor = await floorOf(chatId);
153
+ const content = await promptParts(input.message, input.attachments, chatId, input.signal);
154
+ // The client mints this identity; the harness brands it. A plain UUID
155
+ // is exactly what a browser sends, and it is what makes the prompt
156
+ // idempotent if the same question is ever retried.
157
+ const requestId = randomUUID();
158
+ await ctx.sessionController.prompt({
159
+ requestId,
160
+ sessionId: SessionId(chatId),
161
+ mode: "queue",
162
+ content,
163
+ }, input.signal);
164
+ const agent = ctx.agents.get(SessionId(chatId));
165
+ if (agent === undefined) {
166
+ throw new Error("the QA session has no live agent");
167
+ }
168
+ await whenIdleOrAborted(agent, input.signal);
169
+ const read = await options.sessionLog.read(chatId);
170
+ if (!read.ok) {
171
+ throw new Error(`the session log is unavailable (${read.reason})`);
172
+ }
173
+ // The prompt's own event may be missing from an empty snapshot, so the
174
+ // floor is the later of the cursor taken before the question and the
175
+ // newest human prompt the log knows about. Either way the answer is the
176
+ // last assistant message after it, and no earlier turn can be read as
177
+ // this one's answer.
178
+ const projected = answerAfter(read.events, Math.max(floor, lastPromptSeq(read.events)));
179
+ return Object.freeze({
180
+ chatId,
181
+ answer: projected.answer,
182
+ sources: sourcesOf(chatId),
183
+ interrupted: projected.interrupted,
184
+ });
185
+ },
186
+ async transcript(input) {
187
+ return await transcripts.page(input.chatId, {
188
+ after: input.after,
189
+ limit: input.limit,
190
+ });
191
+ },
192
+ async models() {
193
+ const catalog = await ctx.sessionController.modelCatalog();
194
+ return catalog.groups.flatMap((group) => group.models.map((model) => model.id));
195
+ },
196
+ };
197
+ /**
198
+ * The evidence of the chat's latest turn, for the bridge's citations. The
199
+ * provenance store materializes one bundle per turn; the newest is the one
200
+ * that belongs to the answer just written. A deployment that switched
201
+ * source collection off answers with an empty list rather than no answer.
202
+ */
203
+ function sourcesOf(sessionId) {
204
+ try {
205
+ const bundles = options.provenance.bundles(sessionId);
206
+ return bundles.at(-1)?.sources ?? [];
207
+ }
208
+ catch (error) {
209
+ logger.warn("integration.sources-unavailable", {
210
+ sessionId,
211
+ message: error instanceof Error ? error.message : String(error),
212
+ });
213
+ return [];
214
+ }
215
+ }
216
+ }
217
+ /**
218
+ * The one line that explains the text parts after the question. It says where
219
+ * the material came from and nothing about what to do with it: the deployment's
220
+ * own prompt notes own the instructions, and this block is content.
221
+ */
222
+ const QA_ATTACHMENT_LEAD = "Вложения к этому вопросу — файлы, пришедшие из внешней системы вместе с " +
223
+ "обращением. Их содержимое приведено ниже под заголовком с именем файла.";
224
+ /** Wait for the agent to reach quiescence, or for the caller to give up. */
225
+ async function whenIdleOrAborted(agent, signal) {
226
+ if (signal.aborted)
227
+ throw abortReason(signal);
228
+ await Promise.race([
229
+ agent.whenIdle(),
230
+ new Promise((_resolve, reject) => {
231
+ signal.addEventListener("abort", () => {
232
+ reject(abortReason(signal));
233
+ }, { once: true });
234
+ }),
235
+ ]);
236
+ }
237
+ function abortReason(signal) {
238
+ const reason = signal.reason;
239
+ return reason instanceof Error
240
+ ? reason
241
+ : new Error("the integration request was aborted");
242
+ }
243
+ //# sourceMappingURL=host-runner.js.map