@yadsh/dsh-qa-surface 0.10.0 → 0.11.1

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 +310 -48
  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 +43 -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 +2480 -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 +246 -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 +576 -0
  93. package/lib/integration/http.js.map +1 -0
  94. package/lib/integration/service.js +373 -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 +2 -2
package/README.md CHANGED
@@ -52,7 +52,7 @@ Session and Agent Loop.
52
52
  (list/add/set-password/set-role/disable/revoke), and a coarse honest boundary:
53
53
  accounts identify QA users, they do not fence the harness root;
54
54
  - gives each account a `Настройки` dialog — profile, starter messages,
55
- general, and **personal skills**: ordinary Agent Skills stored as `SKILL.md` in the account's own
55
+ integration tokens, general, and **personal skills**: ordinary Agent Skills stored as `SKILL.md` in the account's own
56
56
  directory (`accounts.skills`), edited with a catalog, an invocation-flag
57
57
  form, a Markdown body, a tool picker over the deployment's registry, and a
58
58
  preview of the exact file a save writes. Skills reach the model through a
@@ -269,6 +269,10 @@ config:
269
269
  # Presets whose sessions may unlock the catalog. Empty leaves the gate open:
270
270
  # only do that when this Host serves one agent composition.
271
271
  activationPresets: []
272
+ # Where the documentation readers look. Empty reads `docs/` inside each
273
+ # chat's own workspace; name the corpus when it is published once, outside
274
+ # every per-user workspace (see "Dynamic QA tools" below).
275
+ docsRoot: ""
272
276
  ```
273
277
 
274
278
  `workspaceId` is recommended for a deterministic assistant. Without it, DSH
@@ -285,12 +289,13 @@ the plugin's catalog is registered into the agent's own scope only after the
285
289
  model successfully loads `tools.activationSkill`, and `qa_tools_selfcheck`
286
290
  reports the resulting state.
287
291
 
288
- As of catalog version 2 the shipped catalog carries two tools:
289
- `qa_tools_selfcheck`, the activation diagnostic, and `file_delete`, the one
290
- destructive capability — it deletes a single regular file strictly inside the
291
- calling chat's workspace and refuses directories, missing paths and anything
292
- that escapes the root, symlink escapes included, with an explicit reason that
293
- never echoes a host path. Every `file_delete` call is answered `ask` by an
292
+ As of catalog version 3 the shipped catalog carries four tools:
293
+ `qa_tools_selfcheck`, the activation diagnostic; `docs_search` and
294
+ `docs_read`, the documentation surface; and `file_delete`, the one destructive
295
+ capability — it deletes a single regular file strictly inside the calling
296
+ chat's workspace and refuses directories, missing paths and anything that
297
+ escapes the root, symlink escapes included, with an explicit reason that never
298
+ echoes a host path. Every `file_delete` call is answered `ask` by an
294
299
  inner gate that sits inside the approval flow, so on a deployment with
295
300
  `interaction.approvals: interactive` the interactive approval card parks the
296
301
  call for the operator, and on `blocked` the call is refused outright: nothing
@@ -299,6 +304,22 @@ dynamic name at execution time — it needs no `lockdown.toolPolicy` entry —
299
304
  and a role-managed deployment grants it through the same Tools baskets as any
300
305
  other tool.
301
306
 
307
+ `docs_search` and `docs_read` are the documentation surface. Documentation is
308
+ published into the `docs/` directory of the chat's workspace and the tools read
309
+ exactly that tree: `docs_search` matches a phrase inside single lines and
310
+ reports every hit with its path and line number, tagged with the module and
311
+ version parsed out of the layout `docs/<module>/<version>/…`; both names are
312
+ also accepted as filters, so a chat that was told "3.8" stops sweeping every
313
+ edition, and `path` narrows a search to one subtree. `docs_read` opens one file
314
+ at a bounded window of lines. Both stay inside the tree — a path outside it, a
315
+ `docs/` that is missing or is not a real directory, a directory passed to a
316
+ read, a binary file and a link that leaves the tree are refused with an
317
+ explicit reason that never echoes a host path — and both bound what they
318
+ return: `limit` and a byte budget on the reported hits, a line budget on a
319
+ read, and a truncated answer says so instead of quietly dropping matches. The
320
+ tool descriptions carry the routing rule the catalog exists for: documentation
321
+ is looked up here, not in memory and not by sweeping guessed paths.
322
+
302
323
  The trigger is the authoritative result of the built-in `skill` tool, not the
303
324
  model's attempt, not a keyword in the transcript, and not a coincidentally
304
325
  matching skill description. A failed or refused load activates nothing, loading
@@ -322,6 +343,17 @@ name. `tools.dynamicActivation: false` restores the always-on behaviour and
322
343
  attaches the catalog to every managed agent at creation — useful for a
323
344
  deployment that would rather debug the tool surface than the trigger.
324
345
 
346
+ `docs_search` and `docs_read` read the `docs/` directory of the calling chat's
347
+ own workspace by default. A deployment that publishes the corpus **once** — for
348
+ example `/workspace/docs` next to a per-user workspace layout — sets
349
+ `tools.docsRoot` to that absolute path, because with
350
+ `accounts.perUserWorkspace: true` the chat's workspace is a per-account
351
+ directory that holds no documentation: the readers would answer "there is no
352
+ documentation tree here" in every chat, while the `glob`/`grep`/`read` tools
353
+ reached the corpus by absolute path all along. The root is held to the same
354
+ rules either way: an absolute path, a real directory, no symlinks, and every
355
+ path is canonicalized and refused if it leaves the tree.
356
+
325
357
  These tools are not `lockdown.toolPolicy.allow` entries, and cannot be: that
326
358
  list is validated against the mounted catalog at attestation time, and a tool
327
359
  that only appears later would fail the check. The QA execution guard authorizes
@@ -462,11 +494,15 @@ in. See
462
494
  [Configuration](https://github.com/xarleyn/dsh-plugins/blob/main/plugins/dsh-qa-surface/docs/CONFIGURATION.md)
463
495
  for the limits and the prompt's exact wording.
464
496
 
465
- The wording of that note — and of the source-provenance and delegation-naming
466
- notes — is editable without touching the source: the «Заметки модели» section
467
- of the settings card (the `notes` config block) mutes each note and rewords
468
- its text, keeping the generated parts (`{identity}`, `{instructions}`,
469
- `{reportTool}`) as placeholders.
497
+ The wording of that note — and of the source-provenance, delegation-naming and
498
+ attached-documents and source-priority notes — is editable without touching
499
+ the source: the «Заметки модели» section of the settings card (the `notes`
500
+ config block) mutes each note and rewords its text, keeping the generated parts
501
+ (`{identity}`, `{instructions}`, `{reportTool}`) as placeholders. The
502
+ attached-documents note is the one that sends a `.docx` or `.pdf` from the chat
503
+ to the document pipeline instead of the plain file reader, which refuses those
504
+ formats as binary. The source-priority note is the one that says an answer
505
+ belongs to the documentation or the expert before it belongs to memory.
470
506
 
471
507
  ### Starter messages
472
508
 
@@ -481,6 +517,26 @@ is pure UI preference — none of it reaches the agent prompt.
481
517
  `accounts.starters.enabled` (default `true`) turns the section off for
482
518
  deployments that want the buttons to stay operator-defined.
483
519
 
520
+ ### Integration tokens
521
+
522
+ The same `Настройки` dialog carries an «Интеграционные токены» section, so the
523
+ account that runs an integration issues and revokes its own credential instead
524
+ of asking the operator to run the CLI. It lists the tokens that account owns —
525
+ label, scopes, creation and expiry dates, use count and last use — mints a new
526
+ one, shows its secret exactly once (with a copy button and the warning that it
527
+ is never recoverable, because only the digest is stored), and revokes one with
528
+ a second confirming click. A revoked or expired token stays in the list as a
529
+ record, with its revoke button gone.
530
+
531
+ Creating is offered only where the credential has somewhere to go: with
532
+ `integration.enabled: false` the section explains that the API is off and hides
533
+ the form, while listing and revoking keep working — a credential that already
534
+ exists has to remain revocable. The section is part of the accounts domain, so
535
+ it appears wherever `accounts.enabled` is on, and the same self-service rules
536
+ apply: the token always belongs to the signed-in account, and one account never
537
+ sees another's tokens. The CLI (`qa-accounts token create|list|revoke`) remains
538
+ the operator's path, including issuing a token for somebody else.
539
+
484
540
  Session policies:
485
541
 
486
542
  - `browser-persistent` restores the id stored in this browser, replacing stale
@@ -584,13 +640,13 @@ lockdown:
584
640
 
585
641
  slashCommands:
586
642
  skills:
587
- mode: allow-list # deny-all | allow-list | all
643
+ mode: allow-list # deny-all | allow-list | all
588
644
  allow:
589
645
  - generate-tkp
590
646
  - generate-tz
591
647
  - gap-analysis
592
648
  commands:
593
- mode: deny-all # deny-all | allow-list | all
649
+ mode: deny-all # deny-all | allow-list | all
594
650
  allow: []
595
651
  palette:
596
652
  enabled: true
@@ -658,42 +714,41 @@ through the `qaSurfacePanels` service; the React body is registered separately
658
714
  in the keyed `qa.surface.panel` slot under the same implementation id:
659
715
 
660
716
  ```ts
661
- import type { Context } from "@deepseek-ai/cordis"
662
- import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots"
663
- import type {} from "@deepseek-ai/dsh-client-ui-renderer/client"
664
- import { QA_SURFACE_PANEL_SLOT } from "@yadsh/dsh-qa-surface/client/panels"
717
+ import type { Context } from "@deepseek-ai/cordis";
718
+ import type { PropsRuntime } from "@deepseek-ai/dsh-client-ui-slots";
719
+ import type {} from "@deepseek-ai/dsh-client-ui-renderer/client";
720
+ import { QA_SURFACE_PANEL_SLOT } from "@yadsh/dsh-qa-surface/client/panels";
665
721
 
666
- const id = "@example/dsh-qa-browser"
722
+ const id = "@example/dsh-qa-browser";
667
723
 
668
724
  function BrowserPanel(props: PropsRuntime<typeof QA_SURFACE_PANEL_SLOT>) {
669
725
  // panelId, panelKind, sessionId, visible, presentation, params,
670
726
  // actions and a registration-lifetime AbortSignal arrive in props.
671
- return null
727
+ return null;
672
728
  }
673
729
 
674
- export const inject = ["slots", "qaSurfacePanels"]
730
+ export const inject = ["slots", "qaSurfacePanels"];
675
731
 
676
732
  export function apply(ctx: Context) {
677
- ctx.effect(() => ctx.qaSurfacePanels.register({
678
- id,
679
- kind: "browser",
680
- title: () => "Browser",
681
- icon: "browser",
682
- order: 100,
683
- keepMounted: true,
684
- }))
733
+ ctx.effect(() =>
734
+ ctx.qaSurfacePanels.register({
735
+ id,
736
+ kind: "browser",
737
+ title: () => "Browser",
738
+ icon: "browser",
739
+ order: 100,
740
+ keepMounted: true,
741
+ }),
742
+ );
685
743
 
686
744
  ctx.slots.inject(QA_SURFACE_PANEL_SLOT, () =>
687
- ctx.slots.register(
688
- { name: QA_SURFACE_PANEL_SLOT, key: id },
689
- BrowserPanel,
690
- ),
691
- )
745
+ ctx.slots.register({ name: QA_SURFACE_PANEL_SLOT, key: id }, BrowserPanel),
746
+ );
692
747
 
693
748
  ctx.qaSurfacePanels.open("browser", {
694
749
  reason: "extension",
695
750
  focus: false,
696
- })
751
+ });
697
752
  }
698
753
  ```
699
754
 
@@ -715,17 +770,19 @@ authorize every Host call server-side and must not persist credentials in the
715
770
  browser.
716
771
 
717
772
  ```ts
718
- import type { Context } from "@deepseek-ai/cordis"
773
+ import type { Context } from "@deepseek-ai/cordis";
719
774
 
720
- export const inject = ["qaUserSettingsSections"]
775
+ export const inject = ["qaUserSettingsSections"];
721
776
 
722
777
  export function apply(ctx: Context) {
723
- ctx.effect(() => ctx.qaUserSettingsSections.register({
724
- id: "integrations",
725
- title: "Интеграции",
726
- order: 40,
727
- component: IntegrationsPage,
728
- }))
778
+ ctx.effect(() =>
779
+ ctx.qaUserSettingsSections.register({
780
+ id: "integrations",
781
+ title: "Интеграции",
782
+ order: 40,
783
+ component: IntegrationsPage,
784
+ }),
785
+ );
729
786
  }
730
787
  ```
731
788
 
@@ -742,9 +799,9 @@ the credential is transport authentication only: never persist it, log it, or
742
799
  put it in a URL, a tool argument or any model-visible value.
743
800
 
744
801
  ```ts
745
- import type { Context } from "@deepseek-ai/cordis"
802
+ import type { Context } from "@deepseek-ai/cordis";
746
803
 
747
- export const inject = ["qaUserSession", "slots"]
804
+ export const inject = ["qaUserSession", "slots"];
748
805
 
749
806
  export function apply(ctx: Context) {
750
807
  ctx.effect(() =>
@@ -752,7 +809,9 @@ export function apply(ctx: Context) {
752
809
  ctx.slots.register(
753
810
  { name: "settings.plugin.item", key: "my-namespace" },
754
811
  MyCard,
755
- )))
812
+ ),
813
+ ),
814
+ );
756
815
  }
757
816
  ```
758
817
 
@@ -916,12 +975,12 @@ metadata:
916
975
  qa-surface:
917
976
  version: 1
918
977
  audience:
919
- type: subroles # or: type: common
978
+ type: subroles # or: type: common
920
979
  include: [analyst, presales]
921
980
  tools:
922
981
  requires: [browser_open, browser_click]
923
982
  grant:
924
- lifecycle: session # the only lifecycle in v1
983
+ lifecycle: session # the only lifecycle in v1
925
984
  requireAll: true
926
985
  ---
927
986
  ```
@@ -947,6 +1006,7 @@ declared audience next to the effective one with a
947
1006
  `Healthy`/`Degraded`/`Blocked` state. Every activation is recorded on the
948
1007
  session record with the requested, granted and denied tools, so a later review
949
1008
  can see what a conversation actually gained.
1009
+
950
1010
  ## Administrative console
951
1011
 
952
1012
  `/qa/admin` is the review and administration surface. It is part of the QA page
@@ -995,6 +1055,208 @@ boundary. Review material is the most sensitive data the package handles, so
995
1055
  tool arguments and results are bounded previews with credential shapes masked,
996
1056
  behind a redactor a deployment can replace.
997
1057
 
1058
+ ## Integration API (HTTP)
1059
+
1060
+ Another application — a ticket-system bridge, a bot, a script — can ask the same
1061
+ assistant questions over HTTP, with its own credential instead of a browser
1062
+ session, and read its own conversations back. The endpoints are **off by
1063
+ default**.
1064
+
1065
+ ```yaml
1066
+ integration:
1067
+ enabled: true # requires accounts.enabled: true
1068
+ basePath: /qa/api # POST {basePath}/ask, GET {basePath}/session, GET {basePath}/health
1069
+ tokenTtlDays: 90
1070
+ requestTimeoutMs: 90000
1071
+ maxConcurrent: 4
1072
+ requestsPerMinute: 60
1073
+ maxAnswerCharacters: 4096 # the answer the ticket comment can hold
1074
+ ```
1075
+
1076
+ The API needs accounts: a caller is an account, and the credential it presents
1077
+ is that account's integration token. Switching it on without
1078
+ `accounts.enabled: true` is refused at configuration time rather than served
1079
+ without authentication.
1080
+
1081
+ ### Issuing a token
1082
+
1083
+ ```bash
1084
+ # the secret is printed exactly once and is never recoverable
1085
+ qa-accounts token create bridge@example.corp --label "ticket bridge" --scopes ask --days 90
1086
+ qa-accounts token list bridge@example.corp # ids, scopes, expiry, last use
1087
+ qa-accounts token revoke bridge@example.corp <token-id>
1088
+ ```
1089
+
1090
+ An integration token is a **separate credential** from the browser token:
1091
+
1092
+ - it survives a password change, because a service that is already integrated
1093
+ must not be logged out by a person editing their own profile;
1094
+ - it stores only a SHA-256 digest of its secret, so a copied database is not a
1095
+ copied credential;
1096
+ - it carries scopes (`ask`, `sessions:read`), an independent expiry and its own
1097
+ revocation;
1098
+ - it stops with the account: disabling the account refuses it, and
1099
+ `qa-accounts revoke <email>` revokes it together with every browser session.
1100
+
1101
+ Use one token per integration and revoke it when the integration is retired.
1102
+ Treat the secret like a password: it is a bearer credential with no second
1103
+ factor.
1104
+
1105
+ ### Asking a question
1106
+
1107
+ ```bash
1108
+ curl -sS https://dsh.example.local/qa/api/ask \
1109
+ -H "Authorization: Bearer qsat.<id>.<secret>" \
1110
+ -H "Content-Type: application/json" \
1111
+ -d '{
1112
+ "message": "TEST получения задач для ИИ Агента\n\nКомпоненты: MDC",
1113
+ "version": "3.8",
1114
+ "session_id": null,
1115
+ "context": {
1116
+ "ticket_key": "PROJ-123",
1117
+ "reporter": "user@example.corp",
1118
+ "reporter_name": "Демо-пользователь"
1119
+ }
1120
+ }'
1121
+ ```
1122
+
1123
+ ```json
1124
+ {
1125
+ "chat_id": "session-1f0c…",
1126
+ "answer": "**Ответ**\n\nТекст ответа в Markdown…",
1127
+ "sources": ["Документация_v3.8.pdf#стр.12"],
1128
+ "confidence": "medium",
1129
+ "escalate": false,
1130
+ "reason": ""
1131
+ }
1132
+ ```
1133
+
1134
+ Send `chat_id` back as `session_id` to continue the same conversation. The chat
1135
+ belongs to the token's account: a token can only continue chats its own account
1136
+ owns, and a chat an integration opened keeps its own workspace, subrole and
1137
+ capability snapshot like any other QA chat.
1138
+
1139
+ `multipart/form-data` is accepted with the same fields (`message`, `version`,
1140
+ `session_id`, `context` as a JSON **string**) plus repeated `files` parts (at
1141
+ most five, 10 MiB each by default).
1142
+
1143
+ | Attachment | What the model receives |
1144
+ | ---------------------------------------------------- | ---------------------------------------------------- |
1145
+ | `image/png`, `image/jpeg`, `image/webp`, `image/gif` | the image itself |
1146
+ | `text/plain`, `text/csv`, `text/markdown` | its text, under a heading with the file name |
1147
+ | `application/pdf`, Word, Excel, PowerPoint | text extracted by the deployment's document pipeline |
1148
+ | anything else | `415` — the fallback the bridge already implements |
1149
+
1150
+ A file the Host cannot read refuses the whole question with `415` rather than
1151
+ being skipped: an answer produced without the material it was asked about is
1152
+ worse than asking again without it. Documents need the `documents` plugin
1153
+ installed; without it PDF and Office attachments take the same `415` path, and
1154
+ images and text keep working. An image the deployment itself refuses — one the
1155
+ selected model cannot see, or one past the Host's image limits — answers the
1156
+ same `415`, with the reason (`MODEL_DOES_NOT_SUPPORT_IMAGES`, `IMAGE_TOO_LARGE`,
1157
+ `IMAGE_TYPE_MISMATCH`, …) in the body and in the Host log. Attachments are read,
1158
+ not stored: the bytes live in a temporary directory for the length of one
1159
+ extraction, and the text is
1160
+ bounded (60 000 characters per file, 120 000 per question, truncated with a
1161
+ marker). Everything the bridge's own filter lets through is accepted; archives,
1162
+ executables and media are not.
1163
+
1164
+ A `answer` longer than `maxAnswerCharacters` is cut before it is published, at
1165
+ the last paragraph break (then the last line, then the last sentence) and marked
1166
+ with an ellipsis, so the ticket shows a readable head instead of a comment the
1167
+ ticket system silently truncates mid-sentence. The cut is logged with the chat
1168
+ id; it is still a normal answer, not an escalation.
1169
+
1170
+ `confidence` is `medium` for a published answer and `low` for an escalated one.
1171
+ `high` is deliberately never claimed: nothing in the deployment judges an
1172
+ answer, and a field that always said `high` would train the operator to ignore
1173
+ it. `escalate: true` means the assistant produced nothing publishable — an
1174
+ interrupted turn, an empty answer, or a question that did not finish inside
1175
+ `requestTimeoutMs` (the `chat_id` is still returned, so a retry continues the
1176
+ same chat instead of starting a second one).
1177
+
1178
+ ### Reading a conversation back
1179
+
1180
+ ```bash
1181
+ # after=0 reads from the start; limit is clamped to 200
1182
+ curl -sS 'https://dsh.example.local/qa/api/session?chat_id=session-1f0c…&after=0&limit=50' \
1183
+ -H "Authorization: Bearer qsat.<id>.<secret>"
1184
+ # {"chat_id":"session-1f0c…",
1185
+ # "messages":[{"seq":1,"role":"user","text":"…","at":"2026-09-21T10:00:00.000Z"},
1186
+ # {"seq":2,"role":"assistant","text":"**Ответ**…","at":null}],
1187
+ # "last_seq":2,"truncated":false}
1188
+ ```
1189
+
1190
+ This is what the `sessions:read` scope is for, and it is a separate scope rather
1191
+ than part of `ask`: a bridge that escalates a ticket, or opens the question in a
1192
+ review screen for a specialist, has to show what was already said. Without the
1193
+ endpoint such a bridge could only ask the same question again — spending a turn
1194
+ to answer a question that already has an answer.
1195
+
1196
+ The read is the same conversation the account owns, in the same words: `text`
1197
+ is the flattening the answer itself uses, so a message reads the same whether it
1198
+ was received as an answer or fetched here. Only the prompts the person sent and
1199
+ the assistant's answers are published — injected context, reasoning and tool
1200
+ traffic are not, because those are model input the caller never wrote.
1201
+
1202
+ `after` is the caller's own cursor: pass the `last_seq` of the previous read,
1203
+ and the next call returns only what was written since. `limit` bounds the page
1204
+ (default 50, at most 200) and the window is always the **newest** messages, so a
1205
+ long conversation costs one page rather than one transcript per call; when older
1206
+ messages were left below the window, `truncated` says so. `at` is the instant
1207
+ the log recorded, or `null` when it recorded none — a timestamp is never
1208
+ invented.
1209
+
1210
+ An unknown `chat_id` and another account's chat answer the same `404`, so an id
1211
+ alone never confirms that somebody else's conversation exists.
1212
+
1213
+ Reading is cheap enough to poll: the newest messages of a conversation are kept
1214
+ warm, and a chat this Host is holding is checked against its own memory, so a
1215
+ page costs neither a stored read nor a walk through the whole history — a page
1216
+ of a long conversation is a page. The first read of an old chat is the
1217
+ expensive one, and a chat this Host does not hold is re-read after 30 seconds at
1218
+ the latest, which is the same budget the review console gives its transcripts.
1219
+
1220
+ ### Health
1221
+
1222
+ ```bash
1223
+ curl -sS https://dsh.example.local/qa/api/health \
1224
+ -H "Authorization: Bearer qsat.<id>.<secret>"
1225
+ # {"ok":true,"version":"0.11.0","models":["gpt-4o-mini"],"uptime_s":86400}
1226
+ ```
1227
+
1228
+ ### Statuses
1229
+
1230
+ | Status | Meaning |
1231
+ | ------ | ------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------------- |
1232
+ | 200 | Answered, or escalated with an empty `answer` |
1233
+ | 400 | Malformed body (no `message`, broken JSON or `context`), a missing `chat_id`, or a cursor that is not a whole number |
1234
+ | 401 | Missing, expired, revoked or unknown token |
1235
+ | 403 | Valid token without the scope the call needs (`ask`, `sessions:read`) |
1236
+ | 404 | A chat the token's account does not own (or that does not exist); also `integration.enabled` is false: no route is registered, and the request reaches whatever the deployment serves for unknown paths |
1237
+ | 413 | Body or attachment over the configured limit |
1238
+ | 415 | Unsupported content type, or an attachment the deployment refused |
1239
+ | 429 | Per-token rate limit or the deployment's concurrency limit |
1240
+ | 503 | The QA assistant failed before it could answer; retry |
1241
+
1242
+ Response bodies carry `{ "error": "…", "code": "…" }` with the same reason
1243
+ vocabulary, so a client can branch on the code instead of parsing prose.
1244
+
1245
+ ### What to watch
1246
+
1247
+ - Every request that reaches a turn is logged on the Host with the token id,
1248
+ the chat id and the ticket key — never the question or the answer. A read is
1249
+ logged the same way, with the cursor it asked from and the number of messages
1250
+ it got.
1251
+ - `maxConcurrent` bounds how much of the deployment a bridge can occupy;
1252
+ `requestsPerMinute` bounds one token. Both answer `429`, which retries well.
1253
+ - `maxAnswerCharacters` bounds one answer. A question that produced more is
1254
+ logged as truncated, with the chat id and the published length — never the
1255
+ text.
1256
+ - The endpoint is a network surface: publish it only where the integration
1257
+ runs, keep TLS in front of it, and remember that the token's scopes are the
1258
+ only limits on what it can ask.
1259
+
998
1260
  ## Security and deployment
999
1261
 
1000
1262
  `/qa` is a presentation boundary, not an authentication boundary. Protect it
@@ -369,7 +369,24 @@ export class QaAccessService {
369
369
  }
370
370
  reserveSession(token, sessionId, requestedSubrole, adminPreview) {
371
371
  const accounts = this.requireAccounts();
372
- const user = accounts.currentUser(token);
372
+ return this.reserveForUser(accounts.currentUser(token), sessionId, requestedSubrole, adminPreview);
373
+ }
374
+ /**
375
+ * The same reservation for a chat opened by an already-authenticated
376
+ * account — the integration API's path. The subrole is the account's own
377
+ * default: an external caller is never offered a choice of capabilities,
378
+ * and an administrator's preview flag stays a browser-only affordance.
379
+ *
380
+ * @param user - the account the chat belongs to.
381
+ * @param sessionId - the Host-generated session id to reserve.
382
+ * @returns the account, as the browser path returns it.
383
+ */
384
+ reserveSessionForUser(user, sessionId) {
385
+ this.requireAccounts();
386
+ return this.reserveForUser(user, sessionId, null, false);
387
+ }
388
+ reserveForUser(user, sessionId, requestedSubrole, adminPreview) {
389
+ const accounts = this.requireAccounts();
373
390
  const config = this.roles.snapshot();
374
391
  const assignment = normalizeUserAccess(accounts.accessOf(user.id), config);
375
392
  const selected = requestedSubrole ?? assignment.defaultSubrole;
@@ -384,7 +401,7 @@ export class QaAccessService {
384
401
  else if (!assignment.allowedSubroles.includes(selected)) {
385
402
  throw new QaAccountsError("invalid-role", "this QA subrole is not assigned to the account");
386
403
  }
387
- const owner = accounts.reserveSession(token, sessionId, {
404
+ const owner = accounts.reserveSessionForUser(user, sessionId, {
388
405
  subroleId: selected,
389
406
  ...(adminPreview ? { adminPreview: true } : {}),
390
407
  });
@@ -395,16 +412,25 @@ export class QaAccessService {
395
412
  this.maybeSweepDelegatedSessions();
396
413
  return owner;
397
414
  }
398
- async policyForSession(token, sessionId, agent) {
415
+ /**
416
+ * The policy of one session, addressed by its authenticated account.
417
+ *
418
+ * Split from {@link policyForSession} so the integration API — whose caller
419
+ * proved an account with a service token instead of a browser one — freezes
420
+ * exactly the same capability snapshot. What the two entry points share is
421
+ * everything after identity; the identity check itself is the only part that
422
+ * differs, and it stays with whoever holds the credential.
423
+ *
424
+ * @param owner - the account that owns the session, or undefined when the
425
+ * deployment has no accounts service at all.
426
+ * @param sessionId - the session being admitted.
427
+ * @param agent - its live agent, for the tool catalog the policy resolves against.
428
+ * @returns the policy, or undefined without an accounts service.
429
+ */
430
+ async policyForSessionOwner(owner, sessionId, agent) {
399
431
  const accounts = this.options.accounts();
400
- if (accounts === undefined)
432
+ if (accounts === undefined || owner === undefined)
401
433
  return undefined;
402
- const owner = accounts.ensureSessionAccess(token, sessionId, {
403
- createdAt: agent.session.header.createdAt,
404
- ...(agent.session.header.parentSession === undefined
405
- ? {}
406
- : { hasParent: true }),
407
- });
408
434
  let record = accounts.sessionAccess(sessionId);
409
435
  const config = this.roles.snapshot();
410
436
  const assignment = normalizeUserAccess(accounts.accessOf(owner.id), config);
@@ -457,6 +483,19 @@ export class QaAccessService {
457
483
  }),
458
484
  };
459
485
  }
486
+ /** The browser's entry point: the token resolves the owning account. */
487
+ async policyForSession(token, sessionId, agent) {
488
+ const accounts = this.options.accounts();
489
+ if (accounts === undefined)
490
+ return undefined;
491
+ const owner = accounts.ensureSessionAccess(token, sessionId, {
492
+ createdAt: agent.session.header.createdAt,
493
+ ...(agent.session.header.parentSession === undefined
494
+ ? {}
495
+ : { hasParent: true }),
496
+ });
497
+ return this.policyForSessionOwner(owner, sessionId, agent);
498
+ }
460
499
  /** Activation history of one session, for review and quality analysis. */
461
500
  skillActivations(token, sessionId) {
462
501
  const { accounts } = this.requireAdmin(token);