@orthacms/copilot-server 0.0.0-reserve.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 (156) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +7 -0
  3. package/dist/index.d.ts +24 -0
  4. package/dist/index.d.ts.map +1 -0
  5. package/dist/index.js +48 -0
  6. package/dist/lib/chat/application/appliers-registrar.d.ts +16 -0
  7. package/dist/lib/chat/application/appliers-registrar.d.ts.map +1 -0
  8. package/dist/lib/chat/application/appliers-registrar.js +50 -0
  9. package/dist/lib/chat/application/capability-profile.service.d.ts +43 -0
  10. package/dist/lib/chat/application/capability-profile.service.d.ts.map +1 -0
  11. package/dist/lib/chat/application/capability-profile.service.js +75 -0
  12. package/dist/lib/chat/application/content-type-summary.service.d.ts +32 -0
  13. package/dist/lib/chat/application/content-type-summary.service.d.ts.map +1 -0
  14. package/dist/lib/chat/application/content-type-summary.service.js +64 -0
  15. package/dist/lib/chat/application/decide-proposal.service.d.ts +80 -0
  16. package/dist/lib/chat/application/decide-proposal.service.d.ts.map +1 -0
  17. package/dist/lib/chat/application/decide-proposal.service.js +112 -0
  18. package/dist/lib/chat/application/dto/create-run.dto.d.ts +138 -0
  19. package/dist/lib/chat/application/dto/create-run.dto.d.ts.map +1 -0
  20. package/dist/lib/chat/application/dto/create-run.dto.js +253 -0
  21. package/dist/lib/chat/application/dto/decide-tool-permission.dto.d.ts +20 -0
  22. package/dist/lib/chat/application/dto/decide-tool-permission.dto.d.ts.map +1 -0
  23. package/dist/lib/chat/application/dto/decide-tool-permission.dto.js +44 -0
  24. package/dist/lib/chat/application/dto/extend-tool-permission.dto.d.ts +18 -0
  25. package/dist/lib/chat/application/dto/extend-tool-permission.dto.d.ts.map +1 -0
  26. package/dist/lib/chat/application/dto/extend-tool-permission.dto.js +33 -0
  27. package/dist/lib/chat/application/dto/list-conversations-query.dto.d.ts +18 -0
  28. package/dist/lib/chat/application/dto/list-conversations-query.dto.d.ts.map +1 -0
  29. package/dist/lib/chat/application/dto/list-conversations-query.dto.js +39 -0
  30. package/dist/lib/chat/application/dto/list-proposals-query.dto.d.ts +15 -0
  31. package/dist/lib/chat/application/dto/list-proposals-query.dto.d.ts.map +1 -0
  32. package/dist/lib/chat/application/dto/list-proposals-query.dto.js +41 -0
  33. package/dist/lib/chat/application/dto/update-conversation.dto.d.ts +68 -0
  34. package/dist/lib/chat/application/dto/update-conversation.dto.d.ts.map +1 -0
  35. package/dist/lib/chat/application/dto/update-conversation.dto.js +115 -0
  36. package/dist/lib/chat/application/proposal-applier.registry.d.ts +27 -0
  37. package/dist/lib/chat/application/proposal-applier.registry.d.ts.map +1 -0
  38. package/dist/lib/chat/application/proposal-applier.registry.js +62 -0
  39. package/dist/lib/chat/application/run-engine.service.d.ts +243 -0
  40. package/dist/lib/chat/application/run-engine.service.d.ts.map +1 -0
  41. package/dist/lib/chat/application/run-engine.service.js +1058 -0
  42. package/dist/lib/chat/application/summarize-tool-output.d.ts +15 -0
  43. package/dist/lib/chat/application/summarize-tool-output.d.ts.map +1 -0
  44. package/dist/lib/chat/application/summarize-tool-output.js +61 -0
  45. package/dist/lib/chat/application/system-prompt.d.ts +81 -0
  46. package/dist/lib/chat/application/system-prompt.d.ts.map +1 -0
  47. package/dist/lib/chat/application/system-prompt.js +477 -0
  48. package/dist/lib/chat/application/tool-permission.broker.d.ts +118 -0
  49. package/dist/lib/chat/application/tool-permission.broker.d.ts.map +1 -0
  50. package/dist/lib/chat/application/tool-permission.broker.js +221 -0
  51. package/dist/lib/chat/http/controllers/create-run.controller.d.ts +30 -0
  52. package/dist/lib/chat/http/controllers/create-run.controller.d.ts.map +1 -0
  53. package/dist/lib/chat/http/controllers/create-run.controller.js +159 -0
  54. package/dist/lib/chat/http/controllers/get-conversation.controller.d.ts +22 -0
  55. package/dist/lib/chat/http/controllers/get-conversation.controller.d.ts.map +1 -0
  56. package/dist/lib/chat/http/controllers/get-conversation.controller.js +50 -0
  57. package/dist/lib/chat/http/controllers/list-conversations.controller.d.ts +20 -0
  58. package/dist/lib/chat/http/controllers/list-conversations.controller.d.ts.map +1 -0
  59. package/dist/lib/chat/http/controllers/list-conversations.controller.js +47 -0
  60. package/dist/lib/chat/http/controllers/list-models.controller.d.ts +35 -0
  61. package/dist/lib/chat/http/controllers/list-models.controller.d.ts.map +1 -0
  62. package/dist/lib/chat/http/controllers/list-models.controller.js +48 -0
  63. package/dist/lib/chat/http/controllers/proposals.controller.d.ts +43 -0
  64. package/dist/lib/chat/http/controllers/proposals.controller.d.ts.map +1 -0
  65. package/dist/lib/chat/http/controllers/proposals.controller.js +98 -0
  66. package/dist/lib/chat/http/controllers/tool-permission.controller.d.ts +57 -0
  67. package/dist/lib/chat/http/controllers/tool-permission.controller.d.ts.map +1 -0
  68. package/dist/lib/chat/http/controllers/tool-permission.controller.js +116 -0
  69. package/dist/lib/chat/http/controllers/update-conversation.controller.d.ts +37 -0
  70. package/dist/lib/chat/http/controllers/update-conversation.controller.d.ts.map +1 -0
  71. package/dist/lib/chat/http/controllers/update-conversation.controller.js +108 -0
  72. package/dist/lib/chat/http/sse-stream.d.ts +41 -0
  73. package/dist/lib/chat/http/sse-stream.d.ts.map +1 -0
  74. package/dist/lib/chat/http/sse-stream.js +95 -0
  75. package/dist/lib/chat/infrastructure/persistence/conversation.repository.d.ts +159 -0
  76. package/dist/lib/chat/infrastructure/persistence/conversation.repository.d.ts.map +1 -0
  77. package/dist/lib/chat/infrastructure/persistence/conversation.repository.js +249 -0
  78. package/dist/lib/chat/infrastructure/persistence/derive-title.d.ts +13 -0
  79. package/dist/lib/chat/infrastructure/persistence/derive-title.d.ts.map +1 -0
  80. package/dist/lib/chat/infrastructure/persistence/derive-title.js +35 -0
  81. package/dist/lib/chat/infrastructure/persistence/proposal.repository.d.ts +86 -0
  82. package/dist/lib/chat/infrastructure/persistence/proposal.repository.d.ts.map +1 -0
  83. package/dist/lib/chat/infrastructure/persistence/proposal.repository.js +152 -0
  84. package/dist/lib/chat/infrastructure/schema/conversations.d.ts +189 -0
  85. package/dist/lib/chat/infrastructure/schema/conversations.d.ts.map +1 -0
  86. package/dist/lib/chat/infrastructure/schema/conversations.js +100 -0
  87. package/dist/lib/chat/infrastructure/schema/external-refs.d.ts +67 -0
  88. package/dist/lib/chat/infrastructure/schema/external-refs.d.ts.map +1 -0
  89. package/dist/lib/chat/infrastructure/schema/external-refs.js +28 -0
  90. package/dist/lib/chat/infrastructure/schema/index.d.ts +5 -0
  91. package/dist/lib/chat/infrastructure/schema/index.d.ts.map +1 -0
  92. package/dist/lib/chat/infrastructure/schema/index.js +12 -0
  93. package/dist/lib/chat/infrastructure/schema/messages.d.ts +264 -0
  94. package/dist/lib/chat/infrastructure/schema/messages.d.ts.map +1 -0
  95. package/dist/lib/chat/infrastructure/schema/messages.js +96 -0
  96. package/dist/lib/chat/infrastructure/schema/proposals.d.ts +328 -0
  97. package/dist/lib/chat/infrastructure/schema/proposals.d.ts.map +1 -0
  98. package/dist/lib/chat/infrastructure/schema/proposals.js +87 -0
  99. package/dist/lib/chat/infrastructure/schema/tool-calls.d.ts +203 -0
  100. package/dist/lib/chat/infrastructure/schema/tool-calls.d.ts.map +1 -0
  101. package/dist/lib/chat/infrastructure/schema/tool-calls.js +50 -0
  102. package/dist/lib/copilot.module.d.ts +30 -0
  103. package/dist/lib/copilot.module.d.ts.map +1 -0
  104. package/dist/lib/copilot.module.js +146 -0
  105. package/dist/lib/copilot.tokens.d.ts +34 -0
  106. package/dist/lib/copilot.tokens.d.ts.map +1 -0
  107. package/dist/lib/copilot.tokens.js +38 -0
  108. package/dist/lib/infrastructure/model-registry.d.ts +22 -0
  109. package/dist/lib/infrastructure/model-registry.d.ts.map +1 -0
  110. package/dist/lib/infrastructure/model-registry.js +54 -0
  111. package/dist/lib/skills/application/dto/create-skill.dto.d.ts +36 -0
  112. package/dist/lib/skills/application/dto/create-skill.dto.d.ts.map +1 -0
  113. package/dist/lib/skills/application/dto/create-skill.dto.js +106 -0
  114. package/dist/lib/skills/application/dto/update-skill.dto.d.ts +28 -0
  115. package/dist/lib/skills/application/dto/update-skill.dto.d.ts.map +1 -0
  116. package/dist/lib/skills/application/dto/update-skill.dto.js +96 -0
  117. package/dist/lib/skills/application/skill-catalog.service.d.ts +97 -0
  118. package/dist/lib/skills/application/skill-catalog.service.d.ts.map +1 -0
  119. package/dist/lib/skills/application/skill-catalog.service.js +157 -0
  120. package/dist/lib/skills/http/controllers/list-skills.controller.d.ts +19 -0
  121. package/dist/lib/skills/http/controllers/list-skills.controller.d.ts.map +1 -0
  122. package/dist/lib/skills/http/controllers/list-skills.controller.js +50 -0
  123. package/dist/lib/skills/http/controllers/manage-skills.controller.d.ts +64 -0
  124. package/dist/lib/skills/http/controllers/manage-skills.controller.d.ts.map +1 -0
  125. package/dist/lib/skills/http/controllers/manage-skills.controller.js +182 -0
  126. package/dist/lib/skills/infrastructure/persistence/skill.repository.d.ts +78 -0
  127. package/dist/lib/skills/infrastructure/persistence/skill.repository.d.ts.map +1 -0
  128. package/dist/lib/skills/infrastructure/persistence/skill.repository.js +116 -0
  129. package/dist/lib/skills/infrastructure/schema/index.d.ts +2 -0
  130. package/dist/lib/skills/infrastructure/schema/index.d.ts.map +1 -0
  131. package/dist/lib/skills/infrastructure/schema/index.js +6 -0
  132. package/dist/lib/skills/infrastructure/schema/skills.d.ts +212 -0
  133. package/dist/lib/skills/infrastructure/schema/skills.d.ts.map +1 -0
  134. package/dist/lib/skills/infrastructure/schema/skills.js +78 -0
  135. package/dist/lib/types/copilot-config.d.ts +55 -0
  136. package/dist/lib/types/copilot-config.d.ts.map +1 -0
  137. package/dist/lib/types/copilot-config.js +2 -0
  138. package/dist/lib/utils/copilot-plugin.d.ts +66 -0
  139. package/dist/lib/utils/copilot-plugin.d.ts.map +1 -0
  140. package/dist/lib/utils/copilot-plugin.js +87 -0
  141. package/migrations/0000_copilot_chat.sql +48 -0
  142. package/migrations/0001_proposals.sql +34 -0
  143. package/migrations/0002_drop_workspace_policies.sql +1 -0
  144. package/migrations/0003_conversation_allowed_tools.sql +1 -0
  145. package/migrations/0004_message_attachments.sql +1 -0
  146. package/migrations/0005_skills.sql +19 -0
  147. package/migrations/0006_conversation_model_choice.sql +1 -0
  148. package/migrations/meta/0000_snapshot.json +390 -0
  149. package/migrations/meta/0001_snapshot.json +657 -0
  150. package/migrations/meta/0002_snapshot.json +610 -0
  151. package/migrations/meta/0003_snapshot.json +617 -0
  152. package/migrations/meta/0004_snapshot.json +623 -0
  153. package/migrations/meta/0005_snapshot.json +771 -0
  154. package/migrations/meta/0006_snapshot.json +777 -0
  155. package/migrations/meta/_journal.json +55 -0
  156. package/package.json +46 -0
@@ -0,0 +1,41 @@
1
+ import type { Response } from 'express';
2
+ import type { CopilotRunEvent } from '@orthacms/copilot-domain';
3
+ /**
4
+ * Writes a run's events to an Express response as Server-Sent Events.
5
+ *
6
+ * **Verified through the admin's Vite dev proxy** (`^/api/` → `:3000`), for
7
+ * both GET and POST: frames arrive one at a time with the same timing as a
8
+ * direct connection, so nothing here is working around a buffering proxy. The
9
+ * headers below still matter for whatever fronts production — `no-transform`
10
+ * stops a compressing proxy from buffering to gzip, and `X-Accel-Buffering: no`
11
+ * is nginx's opt-out.
12
+ */
13
+ export declare class SseStream {
14
+ private readonly res;
15
+ private heartbeat;
16
+ private closed;
17
+ constructor(res: Response);
18
+ /** Sends the headers and starts the heartbeat. */
19
+ open(): void;
20
+ /**
21
+ * Sends one event. The event's `type` becomes the SSE event name, so a
22
+ * client can listen per kind, and the whole event is the JSON payload.
23
+ */
24
+ send(event: CopilotRunEvent): void;
25
+ /** Stops the heartbeat and ends the response. Safe to call twice. */
26
+ close(): void;
27
+ /**
28
+ * Aborts `controller` when the client goes away.
29
+ *
30
+ * **This hangs off the *response*, not the request** — a distinction that
31
+ * costs a whole afternoon if you get it wrong. Express has already consumed
32
+ * the request body by the time a handler runs, and a fully-consumed
33
+ * `IncomingMessage` emits `'close'` immediately, while the client is still
34
+ * connected and waiting. Wiring the abort to `req.on('close')` therefore
35
+ * cancels every run the instant it starts — and because `writeHead` hasn't
36
+ * flushed yet, it presents as a request that hangs with no response at all
37
+ * rather than as an error. Verified directly on Node 22.
38
+ */
39
+ onClientDisconnect(controller: AbortController): void;
40
+ }
41
+ //# sourceMappingURL=sse-stream.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"sse-stream.d.ts","sourceRoot":"","sources":["../../../../src/lib/chat/http/sse-stream.ts"],"names":[],"mappings":"AAAA,OAAO,KAAK,EAAE,QAAQ,EAAE,MAAM,SAAS,CAAC;AACxC,OAAO,KAAK,EAAE,eAAe,EAAE,MAAM,0BAA0B,CAAC;AAShE;;;;;;;;;GASG;AACH,qBAAa,SAAS;IAIN,OAAO,CAAC,QAAQ,CAAC,GAAG;IAHhC,OAAO,CAAC,SAAS,CAA+C;IAChE,OAAO,CAAC,MAAM,CAAS;gBAEM,GAAG,EAAE,QAAQ;IAE1C,kDAAkD;IAClD,IAAI,IAAI,IAAI;IAsBZ;;;OAGG;IACH,IAAI,CAAC,KAAK,EAAE,eAAe,GAAG,IAAI;IAWlC,qEAAqE;IACrE,KAAK,IAAI,IAAI;IAYb;;;;;;;;;;;OAWG;IACH,kBAAkB,CAAC,UAAU,EAAE,eAAe,GAAG,IAAI;CAUxD"}
@@ -0,0 +1,95 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.SseStream = void 0;
4
+ /**
5
+ * How often to send a comment frame while nothing else is happening. A model
6
+ * can think for a while before its first token, and an idle connection is
7
+ * exactly what an intermediary decides to reap.
8
+ */
9
+ const HEARTBEAT_MS = 15_000;
10
+ /**
11
+ * Writes a run's events to an Express response as Server-Sent Events.
12
+ *
13
+ * **Verified through the admin's Vite dev proxy** (`^/api/` → `:3000`), for
14
+ * both GET and POST: frames arrive one at a time with the same timing as a
15
+ * direct connection, so nothing here is working around a buffering proxy. The
16
+ * headers below still matter for whatever fronts production — `no-transform`
17
+ * stops a compressing proxy from buffering to gzip, and `X-Accel-Buffering: no`
18
+ * is nginx's opt-out.
19
+ */
20
+ class SseStream {
21
+ res;
22
+ heartbeat = null;
23
+ closed = false;
24
+ constructor(res) {
25
+ this.res = res;
26
+ }
27
+ /** Sends the headers and starts the heartbeat. */
28
+ open() {
29
+ this.res.writeHead(200, {
30
+ 'Content-Type': 'text/event-stream; charset=utf-8',
31
+ // `no-transform` is the load-bearing half: without it a
32
+ // compressing intermediary may buffer the whole body to compress it.
33
+ 'Cache-Control': 'no-cache, no-transform',
34
+ Connection: 'keep-alive',
35
+ 'X-Accel-Buffering': 'no'
36
+ });
37
+ // `writeHead` alone sends nothing — Node buffers until the first write.
38
+ // Flush a comment now so the client's `fetch` resolves and the UI can
39
+ // render a pending state instead of waiting on the first token.
40
+ this.res.write(': open\n\n');
41
+ this.res.flushHeaders?.();
42
+ this.heartbeat = setInterval(() => {
43
+ if (!this.closed) {
44
+ this.res.write(': ping\n\n');
45
+ }
46
+ }, HEARTBEAT_MS);
47
+ }
48
+ /**
49
+ * Sends one event. The event's `type` becomes the SSE event name, so a
50
+ * client can listen per kind, and the whole event is the JSON payload.
51
+ */
52
+ send(event) {
53
+ if (this.closed) {
54
+ return;
55
+ }
56
+ // JSON.stringify cannot emit a raw newline inside a string, so a single
57
+ // `data:` line is always well-formed — no need to split the payload.
58
+ this.res.write(`event: ${event.type}\ndata: ${JSON.stringify(event)}\n\n`);
59
+ }
60
+ /** Stops the heartbeat and ends the response. Safe to call twice. */
61
+ close() {
62
+ if (this.closed) {
63
+ return;
64
+ }
65
+ this.closed = true;
66
+ if (this.heartbeat) {
67
+ clearInterval(this.heartbeat);
68
+ this.heartbeat = null;
69
+ }
70
+ this.res.end();
71
+ }
72
+ /**
73
+ * Aborts `controller` when the client goes away.
74
+ *
75
+ * **This hangs off the *response*, not the request** — a distinction that
76
+ * costs a whole afternoon if you get it wrong. Express has already consumed
77
+ * the request body by the time a handler runs, and a fully-consumed
78
+ * `IncomingMessage` emits `'close'` immediately, while the client is still
79
+ * connected and waiting. Wiring the abort to `req.on('close')` therefore
80
+ * cancels every run the instant it starts — and because `writeHead` hasn't
81
+ * flushed yet, it presents as a request that hangs with no response at all
82
+ * rather than as an error. Verified directly on Node 22.
83
+ */
84
+ onClientDisconnect(controller) {
85
+ this.res.on('close', () => {
86
+ this.closed = true;
87
+ if (this.heartbeat) {
88
+ clearInterval(this.heartbeat);
89
+ this.heartbeat = null;
90
+ }
91
+ controller.abort();
92
+ });
93
+ }
94
+ }
95
+ exports.SseStream = SseStream;
@@ -0,0 +1,159 @@
1
+ import { type Database } from '@orthacms/database';
2
+ import type { AttachmentRef, ModelContentBlock, SkillRef } from '@orthacms/copilot-domain';
3
+ /** A thread as the list and the transcript render it. */
4
+ export interface ConversationView {
5
+ id: string;
6
+ title: string | null;
7
+ surface: string;
8
+ archived: boolean;
9
+ /**
10
+ * The model last picked for this thread — `null` when nobody has,
11
+ * `'default'` for the host's resolver, else `'<provider>:<model>'`. See the
12
+ * column's own note for why the three states are one nullable text field.
13
+ */
14
+ modelChoice: string | null;
15
+ createdAt: Date;
16
+ updatedAt: Date;
17
+ }
18
+ /** One persisted turn. */
19
+ export interface MessageView {
20
+ id: string;
21
+ runId: string;
22
+ role: 'user' | 'assistant';
23
+ content: ModelContentBlock[];
24
+ /** Files attached to a user turn; null on an assistant turn and when none. */
25
+ attachments: AttachmentRef[] | null;
26
+ /** Skills in force for a user turn; null on an assistant turn and when none. */
27
+ skills: SkillRef[] | null;
28
+ model: string | null;
29
+ provider: string | null;
30
+ stopReason: string | null;
31
+ position: number;
32
+ createdAt: Date;
33
+ }
34
+ /** What {@link ConversationRepository.recordToolCall} writes. */
35
+ export interface ToolCallRecord {
36
+ conversationId: string;
37
+ runId: string;
38
+ callId: string;
39
+ name: string;
40
+ input: unknown;
41
+ outputSummary: string | null;
42
+ ok: boolean;
43
+ error: string | null;
44
+ durationMs: number;
45
+ }
46
+ /**
47
+ * Reads and writes the copilot's transcript.
48
+ *
49
+ * **Every method takes the owning `userId` and `workspaceId` and filters on
50
+ * both.** Not defence in depth for its own sake: `WorkspaceGuard` proves the
51
+ * caller belongs to the workspace they named, but nothing upstream proves a
52
+ * *conversation id* belongs to that user or that workspace — that check has to
53
+ * live here, or a valid session could pull another member's thread by id.
54
+ */
55
+ export declare class ConversationRepository {
56
+ private readonly db;
57
+ constructor(db: Database);
58
+ /**
59
+ * This user's threads in this workspace, most recently used first.
60
+ *
61
+ * `archived` selects **one of two disjoint sets** rather than widening the
62
+ * result: archiving means "hide this from the list", so an archived thread
63
+ * appearing beside its active siblings would defeat the flag entirely.
64
+ */
65
+ list(userId: string, workspaceId: string, archived?: boolean): Promise<ConversationView[]>;
66
+ /**
67
+ * Renames a thread, files it away, and/or records the model picked for it,
68
+ * returning the updated row — or `null` when the id is not this user's in
69
+ * this workspace.
70
+ *
71
+ * **`updatedAt` is deliberately not touched.** It means "last used", and the
72
+ * list sorts by it: bumping it here would send a thread you merely renamed
73
+ * to the top of the rail, above conversations you actually had since. That
74
+ * goes double for `modelChoice`, which the client writes as a side effect of
75
+ * touching a picker — a thread must not climb the rail because someone
76
+ * looked at its model.
77
+ *
78
+ * The ownership predicate is part of the `UPDATE` rather than a read
79
+ * beforehand, so there is no window between checking and writing — and a
80
+ * miss is reported as `null` for "not yours" and "no such id" alike, so an
81
+ * id cannot be probed for existence.
82
+ */
83
+ update(conversationId: string, userId: string, workspaceId: string, patch: {
84
+ title?: string;
85
+ archived?: boolean;
86
+ modelChoice?: string;
87
+ }): Promise<ConversationView | null>;
88
+ /**
89
+ * A thread the caller owns, or `null`. Returns null rather than throwing
90
+ * for "not yours" as well as "no such id" — the two are indistinguishable
91
+ * to a caller, so an id can't be probed for existence.
92
+ */
93
+ find(conversationId: string, userId: string, workspaceId: string): Promise<ConversationView | null>;
94
+ /** Like {@link find}, but 404s — for routes that need the thread present. */
95
+ findOrFail(conversationId: string, userId: string, workspaceId: string): Promise<ConversationView>;
96
+ /**
97
+ * Tool names this thread has been told to stop asking about.
98
+ *
99
+ * Read **per run**, never cached, for the same reason the capability
100
+ * profile is: the list can grow mid-thread (the user answers "allow for
101
+ * this chat" on turn three), and a run holding a snapshot from turn one
102
+ * would ask again for something already answered.
103
+ */
104
+ allowedTools(conversationId: string): Promise<string[]>;
105
+ /**
106
+ * Adds a tool to this thread's allow list.
107
+ *
108
+ * The append happens **in the database**, not by reading and writing back:
109
+ * two calls in one turn can both be answered "allow for this chat" while
110
+ * the run is parked, and a read-modify-write would lose one of them.
111
+ */
112
+ allowTool(conversationId: string, toolName: string): Promise<void>;
113
+ /** Starts a thread owned by `userId` in `workspaceId`. */
114
+ create(userId: string, workspaceId: string, surface: string, firstMessage: string): Promise<ConversationView>;
115
+ /** One thread's turns, in transcript order. */
116
+ messages(conversationId: string): Promise<MessageView[]>;
117
+ /**
118
+ * Appends a turn and bumps the thread's `updatedAt`.
119
+ *
120
+ * The position comes from `max(position) + 1` **computed in SQL**, inside
121
+ * the same statement as the insert, rather than read into JS and written
122
+ * back. Two runs in one thread can overlap (a second question asked while
123
+ * the first is still streaming), and a read-then-write would hand both the
124
+ * same position.
125
+ */
126
+ appendMessage(input: {
127
+ conversationId: string;
128
+ runId: string;
129
+ role: 'user' | 'assistant';
130
+ content: ModelContentBlock[];
131
+ attachments?: AttachmentRef[] | null;
132
+ skills?: SkillRef[] | null;
133
+ model?: string | null;
134
+ provider?: string | null;
135
+ stopReason?: string | null;
136
+ inputTokens?: number | null;
137
+ outputTokens?: number | null;
138
+ }): Promise<{
139
+ id: string;
140
+ position: number;
141
+ }>;
142
+ /** Writes the audit row for one attempted tool call. */
143
+ recordToolCall(record: ToolCallRecord): Promise<void>;
144
+ /** One run's tool calls, oldest first — the UI's step list. */
145
+ toolCalls(runId: string): Promise<{
146
+ id: string;
147
+ conversationId: string;
148
+ runId: string;
149
+ callId: string;
150
+ name: string;
151
+ input: unknown;
152
+ outputSummary: string | null;
153
+ ok: boolean;
154
+ error: string | null;
155
+ durationMs: number;
156
+ createdAt: Date;
157
+ }[]>;
158
+ }
159
+ //# sourceMappingURL=conversation.repository.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"conversation.repository.d.ts","sourceRoot":"","sources":["../../../../../src/lib/chat/infrastructure/persistence/conversation.repository.ts"],"names":[],"mappings":"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"}
@@ -0,0 +1,249 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.ConversationRepository = void 0;
4
+ const tslib_1 = require("tslib");
5
+ const common_1 = require("@nestjs/common");
6
+ const drizzle_orm_1 = require("drizzle-orm");
7
+ const database_1 = require("@orthacms/database");
8
+ const conversations_1 = require("../schema/conversations");
9
+ const messages_1 = require("../schema/messages");
10
+ const tool_calls_1 = require("../schema/tool-calls");
11
+ const derive_title_1 = require("./derive-title");
12
+ /**
13
+ * The columns every conversation read serves.
14
+ *
15
+ * One object rather than four copies: the read shape is `ConversationView`, and
16
+ * a column added to the table but forgotten in one of the selects is a field
17
+ * that is present on the list and missing on the detail — which typechecks.
18
+ */
19
+ const CONVERSATION_COLUMNS = {
20
+ id: conversations_1.copilotConversations.id,
21
+ title: conversations_1.copilotConversations.title,
22
+ surface: conversations_1.copilotConversations.surface,
23
+ archived: conversations_1.copilotConversations.archived,
24
+ modelChoice: conversations_1.copilotConversations.modelChoice,
25
+ createdAt: conversations_1.copilotConversations.createdAt,
26
+ updatedAt: conversations_1.copilotConversations.updatedAt
27
+ };
28
+ /**
29
+ * Reads and writes the copilot's transcript.
30
+ *
31
+ * **Every method takes the owning `userId` and `workspaceId` and filters on
32
+ * both.** Not defence in depth for its own sake: `WorkspaceGuard` proves the
33
+ * caller belongs to the workspace they named, but nothing upstream proves a
34
+ * *conversation id* belongs to that user or that workspace — that check has to
35
+ * live here, or a valid session could pull another member's thread by id.
36
+ */
37
+ let ConversationRepository = class ConversationRepository {
38
+ db;
39
+ constructor(db) {
40
+ this.db = db;
41
+ }
42
+ /**
43
+ * This user's threads in this workspace, most recently used first.
44
+ *
45
+ * `archived` selects **one of two disjoint sets** rather than widening the
46
+ * result: archiving means "hide this from the list", so an archived thread
47
+ * appearing beside its active siblings would defeat the flag entirely.
48
+ */
49
+ async list(userId, workspaceId, archived = false) {
50
+ return this.db
51
+ .select(CONVERSATION_COLUMNS)
52
+ .from(conversations_1.copilotConversations)
53
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(conversations_1.copilotConversations.userId, userId), (0, drizzle_orm_1.eq)(conversations_1.copilotConversations.workspaceId, workspaceId), (0, drizzle_orm_1.eq)(conversations_1.copilotConversations.archived, archived)))
54
+ .orderBy((0, drizzle_orm_1.desc)(conversations_1.copilotConversations.updatedAt));
55
+ }
56
+ /**
57
+ * Renames a thread, files it away, and/or records the model picked for it,
58
+ * returning the updated row — or `null` when the id is not this user's in
59
+ * this workspace.
60
+ *
61
+ * **`updatedAt` is deliberately not touched.** It means "last used", and the
62
+ * list sorts by it: bumping it here would send a thread you merely renamed
63
+ * to the top of the rail, above conversations you actually had since. That
64
+ * goes double for `modelChoice`, which the client writes as a side effect of
65
+ * touching a picker — a thread must not climb the rail because someone
66
+ * looked at its model.
67
+ *
68
+ * The ownership predicate is part of the `UPDATE` rather than a read
69
+ * beforehand, so there is no window between checking and writing — and a
70
+ * miss is reported as `null` for "not yours" and "no such id" alike, so an
71
+ * id cannot be probed for existence.
72
+ */
73
+ async update(conversationId, userId, workspaceId, patch) {
74
+ const [row] = await this.db
75
+ .update(conversations_1.copilotConversations)
76
+ .set({
77
+ ...(patch.title !== undefined ? { title: patch.title } : {}),
78
+ ...(patch.archived !== undefined
79
+ ? { archived: patch.archived }
80
+ : {}),
81
+ ...(patch.modelChoice !== undefined
82
+ ? { modelChoice: patch.modelChoice }
83
+ : {})
84
+ })
85
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(conversations_1.copilotConversations.id, conversationId), (0, drizzle_orm_1.eq)(conversations_1.copilotConversations.userId, userId), (0, drizzle_orm_1.eq)(conversations_1.copilotConversations.workspaceId, workspaceId)))
86
+ .returning(CONVERSATION_COLUMNS);
87
+ return row ?? null;
88
+ }
89
+ /**
90
+ * A thread the caller owns, or `null`. Returns null rather than throwing
91
+ * for "not yours" as well as "no such id" — the two are indistinguishable
92
+ * to a caller, so an id can't be probed for existence.
93
+ */
94
+ async find(conversationId, userId, workspaceId) {
95
+ const [row] = await this.db
96
+ .select(CONVERSATION_COLUMNS)
97
+ .from(conversations_1.copilotConversations)
98
+ .where((0, drizzle_orm_1.and)((0, drizzle_orm_1.eq)(conversations_1.copilotConversations.id, conversationId), (0, drizzle_orm_1.eq)(conversations_1.copilotConversations.userId, userId), (0, drizzle_orm_1.eq)(conversations_1.copilotConversations.workspaceId, workspaceId)))
99
+ .limit(1);
100
+ return row ?? null;
101
+ }
102
+ /** Like {@link find}, but 404s — for routes that need the thread present. */
103
+ async findOrFail(conversationId, userId, workspaceId) {
104
+ const found = await this.find(conversationId, userId, workspaceId);
105
+ if (!found) {
106
+ throw new common_1.NotFoundException('Conversation not found.');
107
+ }
108
+ return found;
109
+ }
110
+ /**
111
+ * Tool names this thread has been told to stop asking about.
112
+ *
113
+ * Read **per run**, never cached, for the same reason the capability
114
+ * profile is: the list can grow mid-thread (the user answers "allow for
115
+ * this chat" on turn three), and a run holding a snapshot from turn one
116
+ * would ask again for something already answered.
117
+ */
118
+ async allowedTools(conversationId) {
119
+ const [row] = await this.db
120
+ .select({ allowedTools: conversations_1.copilotConversations.allowedTools })
121
+ .from(conversations_1.copilotConversations)
122
+ .where((0, drizzle_orm_1.eq)(conversations_1.copilotConversations.id, conversationId))
123
+ .limit(1);
124
+ return row?.allowedTools ?? [];
125
+ }
126
+ /**
127
+ * Adds a tool to this thread's allow list.
128
+ *
129
+ * The append happens **in the database**, not by reading and writing back:
130
+ * two calls in one turn can both be answered "allow for this chat" while
131
+ * the run is parked, and a read-modify-write would lose one of them.
132
+ */
133
+ async allowTool(conversationId, toolName) {
134
+ await this.db
135
+ .update(conversations_1.copilotConversations)
136
+ .set({
137
+ allowedTools: (0, drizzle_orm_1.sql) `(
138
+ select coalesce(jsonb_agg(distinct value), '[]'::jsonb)
139
+ from jsonb_array_elements(
140
+ ${conversations_1.copilotConversations.allowedTools} || ${JSON.stringify([toolName])}::jsonb
141
+ ) as value
142
+ )`
143
+ })
144
+ .where((0, drizzle_orm_1.eq)(conversations_1.copilotConversations.id, conversationId));
145
+ }
146
+ /** Starts a thread owned by `userId` in `workspaceId`. */
147
+ async create(userId, workspaceId, surface, firstMessage) {
148
+ const [row] = await this.db
149
+ .insert(conversations_1.copilotConversations)
150
+ .values({
151
+ userId,
152
+ workspaceId,
153
+ surface,
154
+ title: (0, derive_title_1.deriveTitle)(firstMessage)
155
+ })
156
+ // The view's columns, not `returning()`: a bare one hands back the
157
+ // owner ids and the allow-list too, which is more than any caller of
158
+ // a `ConversationView` should be holding.
159
+ .returning(CONVERSATION_COLUMNS);
160
+ return row;
161
+ }
162
+ /** One thread's turns, in transcript order. */
163
+ async messages(conversationId) {
164
+ return this.db
165
+ .select({
166
+ id: messages_1.copilotMessages.id,
167
+ runId: messages_1.copilotMessages.runId,
168
+ role: messages_1.copilotMessages.role,
169
+ content: messages_1.copilotMessages.content,
170
+ attachments: messages_1.copilotMessages.attachments,
171
+ skills: messages_1.copilotMessages.skills,
172
+ model: messages_1.copilotMessages.model,
173
+ provider: messages_1.copilotMessages.provider,
174
+ stopReason: messages_1.copilotMessages.stopReason,
175
+ position: messages_1.copilotMessages.position,
176
+ createdAt: messages_1.copilotMessages.createdAt
177
+ })
178
+ .from(messages_1.copilotMessages)
179
+ .where((0, drizzle_orm_1.eq)(messages_1.copilotMessages.conversationId, conversationId))
180
+ .orderBy((0, drizzle_orm_1.asc)(messages_1.copilotMessages.position));
181
+ }
182
+ /**
183
+ * Appends a turn and bumps the thread's `updatedAt`.
184
+ *
185
+ * The position comes from `max(position) + 1` **computed in SQL**, inside
186
+ * the same statement as the insert, rather than read into JS and written
187
+ * back. Two runs in one thread can overlap (a second question asked while
188
+ * the first is still streaming), and a read-then-write would hand both the
189
+ * same position.
190
+ */
191
+ async appendMessage(input) {
192
+ const nextPosition = (0, drizzle_orm_1.sql) `(
193
+ select coalesce(max(${messages_1.copilotMessages.position}), 0) + 1
194
+ from ${messages_1.copilotMessages}
195
+ where ${messages_1.copilotMessages.conversationId} = ${input.conversationId}
196
+ )`;
197
+ const [row] = await this.db
198
+ .insert(messages_1.copilotMessages)
199
+ .values({
200
+ conversationId: input.conversationId,
201
+ runId: input.runId,
202
+ role: input.role,
203
+ content: input.content,
204
+ // Null rather than `[]` for "none": the column is read as
205
+ // "did this turn carry files", and an empty array answers that
206
+ // question with a value that has to be length-checked.
207
+ attachments: input.attachments?.length
208
+ ? input.attachments
209
+ : null,
210
+ // Null for "none", for the same reason as `attachments`: the
211
+ // column answers "did this turn run with skills", and an empty
212
+ // array answers it with a value every reader has to length-check.
213
+ skills: input.skills?.length ? input.skills : null,
214
+ model: input.model ?? null,
215
+ provider: input.provider ?? null,
216
+ stopReason: input.stopReason ?? null,
217
+ inputTokens: input.inputTokens ?? null,
218
+ outputTokens: input.outputTokens ?? null,
219
+ position: nextPosition
220
+ })
221
+ .returning({
222
+ id: messages_1.copilotMessages.id,
223
+ position: messages_1.copilotMessages.position
224
+ });
225
+ await this.db
226
+ .update(conversations_1.copilotConversations)
227
+ .set({ updatedAt: new Date() })
228
+ .where((0, drizzle_orm_1.eq)(conversations_1.copilotConversations.id, input.conversationId));
229
+ return row;
230
+ }
231
+ /** Writes the audit row for one attempted tool call. */
232
+ async recordToolCall(record) {
233
+ await this.db.insert(tool_calls_1.copilotToolCalls).values(record);
234
+ }
235
+ /** One run's tool calls, oldest first — the UI's step list. */
236
+ async toolCalls(runId) {
237
+ return this.db
238
+ .select()
239
+ .from(tool_calls_1.copilotToolCalls)
240
+ .where((0, drizzle_orm_1.eq)(tool_calls_1.copilotToolCalls.runId, runId))
241
+ .orderBy((0, drizzle_orm_1.asc)(tool_calls_1.copilotToolCalls.createdAt));
242
+ }
243
+ };
244
+ exports.ConversationRepository = ConversationRepository;
245
+ exports.ConversationRepository = ConversationRepository = tslib_1.__decorate([
246
+ (0, common_1.Injectable)(),
247
+ tslib_1.__param(0, (0, database_1.InjectDatabase)()),
248
+ tslib_1.__metadata("design:paramtypes", [Object])
249
+ ], ConversationRepository);
@@ -0,0 +1,13 @@
1
+ /**
2
+ * Names a thread from its opening message.
3
+ *
4
+ * Deliberately **not** a model call: titling is the kind of thing that quietly
5
+ * doubles the cost and latency of starting a chat, and the first line of what
6
+ * someone typed is a better title than a summary anyway. Phase 4 can revisit it
7
+ * when there is a settings surface to make it optional.
8
+ *
9
+ * Returns `null` for an effectively empty message, leaving the thread untitled
10
+ * rather than titled with whitespace.
11
+ */
12
+ export declare function deriveTitle(message: string): string | null;
13
+ //# sourceMappingURL=derive-title.d.ts.map
@@ -0,0 +1 @@
1
+ {"version":3,"file":"derive-title.d.ts","sourceRoot":"","sources":["../../../../../src/lib/chat/infrastructure/persistence/derive-title.ts"],"names":[],"mappings":"AAGA;;;;;;;;;;GAUG;AACH,wBAAgB,WAAW,CAAC,OAAO,EAAE,MAAM,GAAG,MAAM,GAAG,IAAI,CAkB1D"}
@@ -0,0 +1,35 @@
1
+ "use strict";
2
+ Object.defineProperty(exports, "__esModule", { value: true });
3
+ exports.deriveTitle = deriveTitle;
4
+ /** Longest title we store. Long enough to be recognisable in a list, no more. */
5
+ const MAX_TITLE_LENGTH = 60;
6
+ /**
7
+ * Names a thread from its opening message.
8
+ *
9
+ * Deliberately **not** a model call: titling is the kind of thing that quietly
10
+ * doubles the cost and latency of starting a chat, and the first line of what
11
+ * someone typed is a better title than a summary anyway. Phase 4 can revisit it
12
+ * when there is a settings surface to make it optional.
13
+ *
14
+ * Returns `null` for an effectively empty message, leaving the thread untitled
15
+ * rather than titled with whitespace.
16
+ */
17
+ function deriveTitle(message) {
18
+ // Collapse all whitespace first: a pasted multi-line message would
19
+ // otherwise produce a title with a newline in it, which renders as a
20
+ // broken row rather than a truncated one.
21
+ const collapsed = message.replace(/\s+/g, ' ').trim();
22
+ if (collapsed.length === 0) {
23
+ return null;
24
+ }
25
+ if (collapsed.length <= MAX_TITLE_LENGTH) {
26
+ return collapsed;
27
+ }
28
+ // Prefer cutting at a word boundary, but only if that leaves most of the
29
+ // budget used — otherwise a single very long word would truncate to almost
30
+ // nothing.
31
+ const clipped = collapsed.slice(0, MAX_TITLE_LENGTH);
32
+ const lastSpace = clipped.lastIndexOf(' ');
33
+ const cut = lastSpace > MAX_TITLE_LENGTH * 0.6 ? lastSpace : clipped.length;
34
+ return `${clipped.slice(0, cut).trimEnd()}…`;
35
+ }