@opengeni/api-router 0.12.7 → 0.14.3

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 (89) hide show
  1. package/dist/app.d.ts +18 -38
  2. package/dist/app.js +3 -1
  3. package/dist/auth/managed-auth.d.ts +35 -0
  4. package/dist/{chunk-SD5GJTZ4.js → chunk-36PW33ND.js} +3744 -638
  5. package/dist/chunk-36PW33ND.js.map +1 -0
  6. package/dist/codex-redemption-security.d.ts +15 -0
  7. package/dist/github-access.d.ts +12 -0
  8. package/dist/github-browser-flow.d.ts +14 -0
  9. package/dist/http/auth.d.ts +3 -0
  10. package/dist/http/common.d.ts +3 -0
  11. package/dist/http/sse.d.ts +70 -0
  12. package/dist/index.d.ts +11 -13
  13. package/dist/index.js +1 -1
  14. package/dist/integrations/google-drive.d.ts +76 -0
  15. package/dist/integrations/oauth-client.d.ts +42 -0
  16. package/dist/integrations/provider-domain.d.ts +7 -0
  17. package/dist/integrations/slack-bot.d.ts +343 -0
  18. package/dist/mcp/documents.d.ts +8 -0
  19. package/dist/mcp/files.d.ts +5 -0
  20. package/dist/mcp/server.d.ts +107 -0
  21. package/dist/mcp/session-view.d.ts +191 -0
  22. package/dist/mcp/toolspace.d.ts +61 -0
  23. package/dist/model-catalog.d.ts +39 -0
  24. package/dist/observability.d.ts +3 -0
  25. package/dist/routes/api-keys.d.ts +3 -0
  26. package/dist/routes/billing.d.ts +16 -0
  27. package/dist/routes/capabilities.d.ts +3 -0
  28. package/dist/routes/catalog-assets.d.ts +5 -0
  29. package/dist/routes/codex.d.ts +10 -0
  30. package/dist/routes/connections.d.ts +3 -0
  31. package/dist/routes/documents.d.ts +3 -0
  32. package/dist/routes/enrollments.d.ts +3 -0
  33. package/dist/routes/environments.d.ts +5 -0
  34. package/dist/routes/files.d.ts +4 -0
  35. package/dist/routes/github.d.ts +3 -0
  36. package/dist/routes/insights.d.ts +3 -0
  37. package/dist/routes/install.d.ts +5 -0
  38. package/dist/routes/machines.d.ts +3 -0
  39. package/dist/routes/packs.d.ts +3 -0
  40. package/dist/routes/preference-registry.d.ts +5 -0
  41. package/dist/routes/rigs.d.ts +3 -0
  42. package/dist/routes/scheduled-tasks.d.ts +3 -0
  43. package/dist/routes/sessions.d.ts +15 -0
  44. package/dist/routes/social.d.ts +3 -0
  45. package/dist/routes/transcriptions.d.ts +3 -0
  46. package/dist/routes/workspace-capture.d.ts +59 -0
  47. package/dist/routes/workspace-instruction-policies.d.ts +3 -0
  48. package/dist/routes/workspace-state.d.ts +3 -0
  49. package/dist/routes/workspaces.d.ts +5 -0
  50. package/dist/sandbox/access.d.ts +21 -0
  51. package/dist/sandbox/auth-callout.d.ts +30 -0
  52. package/dist/sandbox/channel-a.d.ts +37 -0
  53. package/dist/sandbox/enrollment.d.ts +120 -0
  54. package/dist/sandbox/machines.d.ts +29 -0
  55. package/dist/sandbox/metrics-ingestion.d.ts +128 -0
  56. package/dist/sandbox/rematerialize.d.ts +24 -0
  57. package/dist/sandbox/viewer.d.ts +251 -0
  58. package/dist/transcription/providers/azure-openai.d.ts +9 -0
  59. package/dist/transcription/providers/codex-subscription.d.ts +9 -0
  60. package/dist/transcription/providers/openai.d.ts +9 -0
  61. package/dist/transcription/service.d.ts +10 -0
  62. package/dist/workspace-state-projection.d.ts +16 -0
  63. package/package.json +13 -13
  64. package/src/app.ts +75 -5
  65. package/src/github-access.ts +1 -0
  66. package/src/integrations/google-drive.ts +869 -0
  67. package/src/integrations/oauth-client.ts +41 -6
  68. package/src/integrations/slack-bot.ts +756 -26
  69. package/src/mcp/server.ts +226 -42
  70. package/src/mcp/session-view.ts +1 -0
  71. package/src/mcp/toolspace.ts +17 -9
  72. package/src/routes/connections.ts +137 -7
  73. package/src/routes/documents.ts +13 -2
  74. package/src/routes/github.ts +327 -20
  75. package/src/routes/insights.ts +30 -0
  76. package/src/routes/preference-registry.ts +482 -0
  77. package/src/routes/sessions.ts +22 -23
  78. package/src/routes/transcriptions.ts +155 -0
  79. package/src/routes/workspace-capture.ts +108 -8
  80. package/src/routes/workspace-state.ts +61 -0
  81. package/src/sandbox/channel-a.ts +2 -0
  82. package/src/sandbox/rematerialize.ts +111 -4
  83. package/src/sandbox/viewer.ts +29 -12
  84. package/src/transcription/providers/azure-openai.ts +45 -0
  85. package/src/transcription/providers/codex-subscription.ts +100 -0
  86. package/src/transcription/providers/openai.ts +93 -0
  87. package/src/transcription/service.ts +121 -0
  88. package/src/workspace-state-projection.ts +244 -0
  89. package/dist/chunk-SD5GJTZ4.js.map +0 -1
@@ -0,0 +1,45 @@
1
+ import type { TranscriptionProvider } from "@opengeni/core";
2
+ import { fetchError, responseError } from "./openai";
3
+
4
+ export function createAzureOpenAiTranscriptionProvider(input: {
5
+ endpoint: string;
6
+ deployment: string;
7
+ apiVersion: string;
8
+ apiKey: string | null;
9
+ adToken: string | null;
10
+ fetch?: typeof fetch;
11
+ }): TranscriptionProvider {
12
+ const fetchImpl = input.fetch ?? fetch;
13
+ const url = `${input.endpoint}/openai/deployments/${encodeURIComponent(input.deployment)}/audio/transcriptions?api-version=${encodeURIComponent(input.apiVersion)}`;
14
+ return {
15
+ id: "azure-openai",
16
+ available: () => Boolean(input.apiKey || input.adToken),
17
+ async transcribe({ audio, mimeType, filename, signal }) {
18
+ const form = new FormData();
19
+ form.append("file", new Blob([Uint8Array.from(audio).buffer], { type: mimeType }), filename);
20
+ const headers: Record<string, string> = input.apiKey
21
+ ? { "api-key": input.apiKey }
22
+ : { Authorization: `Bearer ${input.adToken}` };
23
+ let response: Response;
24
+ try {
25
+ response = await fetchImpl(url, {
26
+ method: "POST",
27
+ headers,
28
+ body: form,
29
+ ...(signal ? { signal } : {}),
30
+ });
31
+ } catch (error) {
32
+ throw fetchError(error);
33
+ }
34
+ if (!response.ok) throw responseError(response.status);
35
+ const body = await response.json().catch(() => null);
36
+ if (!body || typeof body.text !== "string") {
37
+ throw responseError(502);
38
+ }
39
+ return {
40
+ text: body.text,
41
+ languages: typeof body.language === "string" && body.language ? [body.language] : [],
42
+ };
43
+ },
44
+ };
45
+ }
@@ -0,0 +1,100 @@
1
+ import { CODEX_CLIENT_VERSION, CODEX_ORIGINATOR } from "@opengeni/codex/constants";
2
+ import type { Settings } from "@opengeni/config";
3
+ import {
4
+ type TranscriptionAvailabilityContext,
5
+ type TranscriptionProvider,
6
+ TranscriptionServiceError,
7
+ } from "@opengeni/core";
8
+ import { buildCodexTokenResolver, type Database, listCodexAccountStatuses } from "@opengeni/db";
9
+ import { fetchError, responseError } from "./openai";
10
+
11
+ const TRANSCRIBE_URL = "https://chatgpt.com/backend-api/transcribe";
12
+
13
+ async function workspaceHasActiveCodexAccount(db: Database, workspaceId: string): Promise<boolean> {
14
+ const account = (await listCodexAccountStatuses(db, workspaceId)).find(
15
+ (candidate) => candidate.isActive && candidate.status === "active",
16
+ );
17
+ return account != null;
18
+ }
19
+
20
+ export function createCodexSubscriptionTranscriptionProvider(input: {
21
+ settings: Settings;
22
+ db: Database;
23
+ fetch?: typeof fetch;
24
+ probe?: (context?: TranscriptionAvailabilityContext) => boolean | Promise<boolean>;
25
+ }): TranscriptionProvider {
26
+ const fetchImpl = input.fetch ?? fetch;
27
+ const probe =
28
+ input.probe ??
29
+ (async (context?: TranscriptionAvailabilityContext) => {
30
+ // Deployment-level readiness: registry construction already gated this
31
+ // provider on OPENGENI_CODEX_SUBSCRIPTION_ENABLED. Request selection
32
+ // passes workspaceId so we only claim Codex when a subscription is
33
+ // attached; otherwise OpenAI/Azure remain eligible.
34
+ if (!context?.workspaceId) return true;
35
+ return await workspaceHasActiveCodexAccount(input.db, context.workspaceId);
36
+ });
37
+ return {
38
+ id: "codex-subscription",
39
+ experimental: true,
40
+ available: probe,
41
+ async transcribe({ audio, mimeType, filename, workspaceId, signal }) {
42
+ const account = (await listCodexAccountStatuses(input.db, workspaceId)).find(
43
+ (candidate) => candidate.isActive && candidate.status === "active",
44
+ );
45
+ if (!account) {
46
+ throw new TranscriptionServiceError({
47
+ code: "unavailable",
48
+ message: "Transcription is unavailable.",
49
+ });
50
+ }
51
+ const resolver = buildCodexTokenResolver(input.db, input.settings, workspaceId, account.id);
52
+ let token: Awaited<ReturnType<typeof resolver.getToken>>;
53
+ try {
54
+ token = await resolver.getToken();
55
+ } catch {
56
+ throw new TranscriptionServiceError({
57
+ code: "unavailable",
58
+ message: "Transcription is unavailable.",
59
+ });
60
+ }
61
+ const request = async (accessToken: string, accountId: string | null) => {
62
+ const form = new FormData();
63
+ form.append(
64
+ "file",
65
+ new Blob([Uint8Array.from(audio).buffer], { type: mimeType }),
66
+ filename,
67
+ );
68
+ return await fetchImpl(TRANSCRIBE_URL, {
69
+ method: "POST",
70
+ headers: {
71
+ Authorization: `Bearer ${accessToken}`,
72
+ ...(accountId ? { "ChatGPT-Account-ID": accountId } : {}),
73
+ originator: CODEX_ORIGINATOR,
74
+ "User-Agent": `${CODEX_ORIGINATOR}/${CODEX_CLIENT_VERSION}`,
75
+ version: CODEX_CLIENT_VERSION,
76
+ },
77
+ body: form,
78
+ ...(signal ? { signal } : {}),
79
+ });
80
+ };
81
+ let response: Response;
82
+ try {
83
+ response = await request(token.accessToken, token.chatgptAccountId);
84
+ if (response.status === 401) {
85
+ token = await resolver.refresh();
86
+ response = await request(token.accessToken, token.chatgptAccountId);
87
+ }
88
+ } catch (error) {
89
+ throw fetchError(error);
90
+ }
91
+ if (!response.ok) throw responseError(response.status);
92
+ const body = await response.json().catch(() => null);
93
+ if (!body || typeof body.text !== "string") throw responseError(502);
94
+ return {
95
+ text: body.text,
96
+ languages: typeof body.language === "string" && body.language ? [body.language] : [],
97
+ };
98
+ },
99
+ };
100
+ }
@@ -0,0 +1,93 @@
1
+ import { type TranscriptionProvider, TranscriptionServiceError } from "@opengeni/core";
2
+
3
+ export function createOpenAiTranscriptionProvider(input: {
4
+ apiKey: string;
5
+ baseUrl: string;
6
+ model: string;
7
+ fetch?: typeof fetch;
8
+ }): TranscriptionProvider {
9
+ const fetchImpl = input.fetch ?? fetch;
10
+ return {
11
+ id: "openai",
12
+ available: () => true,
13
+ async transcribe({ audio, mimeType, filename, signal }) {
14
+ const form = new FormData();
15
+ form.append("file", audioBlob(audio, mimeType), filename);
16
+ form.append("model", input.model);
17
+ let response: Response;
18
+ try {
19
+ response = await fetchImpl(`${input.baseUrl}/audio/transcriptions`, {
20
+ method: "POST",
21
+ headers: { Authorization: `Bearer ${input.apiKey}` },
22
+ body: form,
23
+ ...(signal ? { signal } : {}),
24
+ });
25
+ } catch (error) {
26
+ throw fetchError(error);
27
+ }
28
+ if (!response.ok) throw responseError(response.status);
29
+ const body = await response.json().catch(() => null);
30
+ if (!body || typeof body.text !== "string") {
31
+ throw new TranscriptionServiceError({
32
+ code: "provider",
33
+ message: "Invalid transcription response.",
34
+ });
35
+ }
36
+ return {
37
+ text: body.text,
38
+ languages: typeof body.language === "string" && body.language ? [body.language] : [],
39
+ };
40
+ },
41
+ };
42
+ }
43
+
44
+ function audioBlob(audio: Uint8Array, mimeType: string): Blob {
45
+ return new Blob([Uint8Array.from(audio).buffer], { type: mimeType });
46
+ }
47
+
48
+ export function responseError(status: number): TranscriptionServiceError {
49
+ if (status === 401 || status === 403) {
50
+ return new TranscriptionServiceError({
51
+ code: "unavailable",
52
+ message: "Transcription is unavailable.",
53
+ });
54
+ }
55
+ if (status === 413) {
56
+ return new TranscriptionServiceError({
57
+ code: "too_large",
58
+ message: "Audio is too large.",
59
+ });
60
+ }
61
+ if (status === 400 || status === 422) {
62
+ return new TranscriptionServiceError({
63
+ code: "invalid_audio",
64
+ message: "Audio could not be transcribed.",
65
+ });
66
+ }
67
+ if (status === 408 || status === 504) {
68
+ return new TranscriptionServiceError({
69
+ code: "timeout",
70
+ message: "Transcription timed out.",
71
+ retryable: true,
72
+ });
73
+ }
74
+ return new TranscriptionServiceError({
75
+ code: status >= 500 ? "unavailable" : "provider",
76
+ message: "Transcription provider failed.",
77
+ retryable: status >= 500,
78
+ });
79
+ }
80
+
81
+ export function fetchError(error: unknown): TranscriptionServiceError {
82
+ if (error instanceof DOMException && error.name === "AbortError") {
83
+ return new TranscriptionServiceError({
84
+ code: "cancelled",
85
+ message: "Transcription was cancelled.",
86
+ });
87
+ }
88
+ return new TranscriptionServiceError({
89
+ code: "network",
90
+ message: "Transcription provider is unreachable.",
91
+ retryable: true,
92
+ });
93
+ }
@@ -0,0 +1,121 @@
1
+ import { resolveVoiceInputProviderRegistry, type Settings } from "@opengeni/config";
2
+ import { VOICE_INPUT_ACCEPTED_MIME_TYPES } from "@opengeni/contracts";
3
+ import {
4
+ filenameForMimeType,
5
+ isAcceptedMimeType,
6
+ normalizeMimeType,
7
+ type TranscriptionAvailabilityContext,
8
+ type TranscriptionProvider,
9
+ type TranscriptionService,
10
+ TranscriptionServiceError,
11
+ } from "@opengeni/core";
12
+ import type { Database } from "@opengeni/db";
13
+ import { createAzureOpenAiTranscriptionProvider } from "./providers/azure-openai";
14
+ import { createCodexSubscriptionTranscriptionProvider } from "./providers/codex-subscription";
15
+ import { createOpenAiTranscriptionProvider } from "./providers/openai";
16
+
17
+ export function createTranscriptionService(input: {
18
+ settings: Settings;
19
+ db: Database;
20
+ fetch?: typeof fetch;
21
+ codexFetch?: typeof fetch;
22
+ probeCodex?: (context?: TranscriptionAvailabilityContext) => boolean | Promise<boolean>;
23
+ }): TranscriptionService {
24
+ const providers: TranscriptionProvider[] = resolveVoiceInputProviderRegistry(input.settings).map(
25
+ (config) => {
26
+ switch (config.kind) {
27
+ case "openai":
28
+ return createOpenAiTranscriptionProvider({
29
+ ...config,
30
+ ...(input.fetch ? { fetch: input.fetch } : {}),
31
+ });
32
+ case "azure-openai":
33
+ return createAzureOpenAiTranscriptionProvider({
34
+ ...config,
35
+ ...(input.fetch ? { fetch: input.fetch } : {}),
36
+ });
37
+ case "codex-subscription":
38
+ return createCodexSubscriptionTranscriptionProvider({
39
+ settings: input.settings,
40
+ db: input.db,
41
+ ...(input.codexFetch ? { fetch: input.codexFetch } : {}),
42
+ ...(input.probeCodex ? { probe: input.probeCodex } : {}),
43
+ });
44
+ default:
45
+ throw new Error("Unsupported voice-input provider.");
46
+ }
47
+ },
48
+ );
49
+ const limits = {
50
+ maxDurationSeconds: input.settings.voiceInputMaxDurationSeconds,
51
+ maxSizeBytes: input.settings.voiceInputMaxSizeBytes,
52
+ acceptedMimeTypes: [...VOICE_INPUT_ACCEPTED_MIME_TYPES],
53
+ };
54
+ return {
55
+ limits: () => limits,
56
+ async available(context) {
57
+ return (await Promise.all(providers.map((provider) => provider.available(context)))).some(
58
+ Boolean,
59
+ );
60
+ },
61
+ async transcribe(request) {
62
+ const mimeType = normalizeMimeType(request.mimeType);
63
+ if (!isAcceptedMimeType(mimeType, limits.acceptedMimeTypes)) {
64
+ throw new TranscriptionServiceError({
65
+ code: "not_supported",
66
+ message: "Unsupported audio format.",
67
+ });
68
+ }
69
+ if (request.audio.byteLength > limits.maxSizeBytes) {
70
+ throw new TranscriptionServiceError({
71
+ code: "too_large",
72
+ message: "Audio is too large.",
73
+ });
74
+ }
75
+ if (
76
+ request.durationSeconds !== undefined &&
77
+ (!Number.isFinite(request.durationSeconds) ||
78
+ request.durationSeconds < 0 ||
79
+ request.durationSeconds > limits.maxDurationSeconds)
80
+ ) {
81
+ throw new TranscriptionServiceError({
82
+ code: "invalid_audio",
83
+ message: "Invalid audio duration.",
84
+ });
85
+ }
86
+ const provider = await firstAvailable(providers, {
87
+ workspaceId: request.workspaceId,
88
+ });
89
+ if (!provider) {
90
+ throw new TranscriptionServiceError({
91
+ code: "unavailable",
92
+ message: "Transcription is unavailable.",
93
+ });
94
+ }
95
+ const startedAt = performance.now();
96
+ const result = await provider.transcribe({
97
+ audio: request.audio,
98
+ mimeType,
99
+ filename: filenameForMimeType(mimeType),
100
+ workspaceId: request.workspaceId,
101
+ signal: request.signal,
102
+ });
103
+ return {
104
+ ...result,
105
+ providerId: provider.id,
106
+ audioSeconds: request.durationSeconds ?? 0,
107
+ latencyMs: Math.round(performance.now() - startedAt),
108
+ };
109
+ },
110
+ };
111
+ }
112
+
113
+ async function firstAvailable(
114
+ providers: readonly TranscriptionProvider[],
115
+ context: TranscriptionAvailabilityContext,
116
+ ) {
117
+ for (const provider of providers) {
118
+ if (await provider.available(context)) return provider;
119
+ }
120
+ return null;
121
+ }
@@ -0,0 +1,244 @@
1
+ import {
2
+ KnowledgeMemoryKind,
3
+ KnowledgeMemoryStatus,
4
+ WORKSPACE_STATE_BASE_NAME_MAX_CHARS,
5
+ WORKSPACE_STATE_MAX_ACTIVE_POLICY_HEADS,
6
+ WORKSPACE_STATE_MAX_BASES,
7
+ WORKSPACE_STATE_MAX_GAPS,
8
+ WORKSPACE_STATE_MAX_TOPICS,
9
+ WORKSPACE_STATE_MEMORY_SAMPLE_LIMIT,
10
+ WORKSPACE_STATE_TOPIC_MAX_CHARS,
11
+ WorkspaceStateResponse,
12
+ type WorkspaceInstructionPolicyListResponse,
13
+ type WorkspaceStateGap,
14
+ type WorkspaceStateMemoryKindCounts,
15
+ type WorkspaceStateMemoryStatusCounts,
16
+ type WorkspaceStateResponse as WorkspaceStateResponseType,
17
+ } from "@opengeni/contracts";
18
+ import type { WorkspaceStateMemoryRecord } from "@opengeni/db";
19
+ import type { DocumentInventory } from "@opengeni/documents";
20
+
21
+ type KnowledgeProjectionInput = {
22
+ documents: DocumentInventory;
23
+ memories: WorkspaceStateMemoryRecord[];
24
+ };
25
+
26
+ export type WorkspaceStateProjectionInput = {
27
+ workspaceId: string;
28
+ generatedAt: string;
29
+ workspaceAgentInstructions: string | null;
30
+ policies: WorkspaceInstructionPolicyListResponse;
31
+ knowledge: KnowledgeProjectionInput | null;
32
+ };
33
+
34
+ function emptyMemoryStatusCounts(): WorkspaceStateMemoryStatusCounts {
35
+ return Object.fromEntries(
36
+ KnowledgeMemoryStatus.options.map((status) => [status, 0]),
37
+ ) as WorkspaceStateMemoryStatusCounts;
38
+ }
39
+
40
+ function emptyMemoryKindCounts(): WorkspaceStateMemoryKindCounts {
41
+ return Object.fromEntries(
42
+ KnowledgeMemoryKind.options.map((kind) => [kind, 0]),
43
+ ) as WorkspaceStateMemoryKindCounts;
44
+ }
45
+
46
+ function boundedLabel(value: string, maxChars: number, fallback: string): string {
47
+ const normalized = value.normalize("NFKC").trim().replace(/\s+/gu, " ");
48
+ return (normalized || fallback).slice(0, maxChars);
49
+ }
50
+
51
+ function newestTimestamp(values: Array<string | null>): string | null {
52
+ return values.reduce<string | null>((latest, value) => {
53
+ if (!value) return latest;
54
+ return latest === null || value > latest ? value : latest;
55
+ }, null);
56
+ }
57
+
58
+ function comparePolicyTargets(
59
+ left: WorkspaceInstructionPolicyListResponse["activeHeads"][number],
60
+ right: WorkspaceInstructionPolicyListResponse["activeHeads"][number],
61
+ ): number {
62
+ return `${left.kind}:${left.scope}:${left.roleKey ?? ""}`.localeCompare(
63
+ `${right.kind}:${right.scope}:${right.roleKey ?? ""}`,
64
+ );
65
+ }
66
+
67
+ function policyProjection(input: WorkspaceStateProjectionInput) {
68
+ const sortedHeads = [...input.policies.activeHeads].sort(comparePolicyTargets);
69
+ const activeHeads = sortedHeads.slice(0, WORKSPACE_STATE_MAX_ACTIVE_POLICY_HEADS).map((head) => ({
70
+ kind: head.kind,
71
+ scope: head.scope,
72
+ roleKey: head.roleKey,
73
+ revisionId: head.revisionId,
74
+ revision: head.revision,
75
+ contentHash: head.contentHash,
76
+ activationVersion: head.activationVersion,
77
+ activatedAt: head.activatedAt,
78
+ }));
79
+ const newestRevision = input.policies.revisions[0] ?? null;
80
+ return {
81
+ authority: "workspace_instruction_policy_heads" as const,
82
+ activeHeads,
83
+ activeHeadsTruncated: sortedHeads.length > activeHeads.length,
84
+ latestRevision: newestRevision
85
+ ? {
86
+ kind: newestRevision.kind,
87
+ scope: newestRevision.scope,
88
+ roleKey: newestRevision.roleKey,
89
+ revisionId: newestRevision.id,
90
+ revision: newestRevision.revision,
91
+ contentHash: newestRevision.contentHash,
92
+ provenanceSource: newestRevision.provenance.source,
93
+ state: input.policies.activeHeads.some((head) => head.revisionId === newestRevision.id)
94
+ ? ("active" as const)
95
+ : ("inactive" as const),
96
+ createdAt: newestRevision.createdAt,
97
+ }
98
+ : null,
99
+ legacyRuntime: {
100
+ source: input.workspaceAgentInstructions
101
+ ? ("workspace_override" as const)
102
+ : ("deployment_default" as const),
103
+ workspaceOverrideConfigured: Boolean(input.workspaceAgentInstructions),
104
+ },
105
+ runtimeComposition: { status: "not_implemented" as const },
106
+ };
107
+ }
108
+
109
+ function availableKnowledgeProjection(knowledge: KnowledgeProjectionInput) {
110
+ const inventory = knowledge.documents;
111
+ const selectedBases = inventory.bases.slice(0, WORKSPACE_STATE_MAX_BASES);
112
+ const aggregateStatuses = { ...inventory.statusCounts };
113
+ const aggregateSources = { ...inventory.sourceKindCounts };
114
+ const topicCounts = new Map<string, number>();
115
+ const projectedBases = selectedBases.map((base) => {
116
+ return {
117
+ id: base.id,
118
+ name: boundedLabel(base.name, WORKSPACE_STATE_BASE_NAME_MAX_CHARS, "Untitled base"),
119
+ visibleDocumentCount: base.visibleDocumentCount,
120
+ statusCounts: base.statusCounts,
121
+ latestUpdatedAt: base.latestUpdatedAt,
122
+ };
123
+ });
124
+
125
+ for (const topic of inventory.topics) {
126
+ const name = boundedLabel(topic.name, WORKSPACE_STATE_TOPIC_MAX_CHARS, "Unlabeled topic");
127
+ topicCounts.set(name, (topicCounts.get(name) ?? 0) + topic.documentCount);
128
+ }
129
+
130
+ const sortedTopics = [...topicCounts.entries()]
131
+ .sort(
132
+ ([leftName, leftCount], [rightName, rightCount]) =>
133
+ rightCount - leftCount || leftName.localeCompare(rightName),
134
+ )
135
+ .map(([name, documentCount]) => ({ name, documentCount }));
136
+ const topics = sortedTopics.slice(0, WORKSPACE_STATE_MAX_TOPICS);
137
+ const memories = knowledge.memories.slice(0, WORKSPACE_STATE_MEMORY_SAMPLE_LIMIT);
138
+ const memoryStatuses = emptyMemoryStatusCounts();
139
+ const memoryKinds = emptyMemoryKindCounts();
140
+ for (const memory of memories) {
141
+ memoryStatuses[memory.status] += 1;
142
+ memoryKinds[memory.kind] += 1;
143
+ }
144
+
145
+ const inspectedVisibleDocumentCount = inventory.visibleDocumentCount;
146
+ const basesTruncated = inventory.baseCount > projectedBases.length;
147
+ const memoryLimitReached = memories.length === WORKSPACE_STATE_MEMORY_SAMPLE_LIMIT;
148
+ const inventoryPartial = basesTruncated || inventory.topicsTruncated || memoryLimitReached;
149
+ const gaps: WorkspaceStateGap[] = [];
150
+ const addGap = (gap: WorkspaceStateGap): void => {
151
+ if (gaps.length < WORKSPACE_STATE_MAX_GAPS) gaps.push(gap);
152
+ };
153
+ if (inventory.baseCount === 0) {
154
+ addGap({ code: "no_document_bases", severity: "info", relatedCount: 0 });
155
+ } else if (inspectedVisibleDocumentCount === 0) {
156
+ addGap({ code: "no_visible_documents", severity: "info", relatedCount: 0 });
157
+ }
158
+ if (aggregateStatuses.failed > 0) {
159
+ addGap({
160
+ code: "failed_documents",
161
+ severity: "warning",
162
+ relatedCount: aggregateStatuses.failed,
163
+ });
164
+ }
165
+ const processingCount = aggregateStatuses.queued + aggregateStatuses.indexing;
166
+ if (processingCount > 0) {
167
+ addGap({
168
+ code: "processing_documents",
169
+ severity: "info",
170
+ relatedCount: processingCount,
171
+ });
172
+ }
173
+ if (aggregateStatuses.ready > 0 && sortedTopics.length === 0) {
174
+ addGap({
175
+ code: "missing_topic_coverage",
176
+ severity: "info",
177
+ relatedCount: aggregateStatuses.ready,
178
+ });
179
+ }
180
+ if (memories.length === 0) {
181
+ addGap({ code: "no_memory_records", severity: "info", relatedCount: 0 });
182
+ }
183
+ if (memoryStatuses.proposed > 0) {
184
+ addGap({
185
+ code: "pending_memory_review",
186
+ severity: "info",
187
+ relatedCount: memoryStatuses.proposed,
188
+ });
189
+ }
190
+ if (inventoryPartial) {
191
+ addGap({ code: "partial_inventory", severity: "info", relatedCount: null });
192
+ }
193
+
194
+ return {
195
+ availability: "available" as const,
196
+ coverage: inventoryPartial ? ("partial" as const) : ("complete" as const),
197
+ baseCount: inventory.baseCount,
198
+ bases: projectedBases,
199
+ basesTruncated,
200
+ inspectedVisibleDocumentCount,
201
+ documentStatusCounts: aggregateStatuses,
202
+ sourceKindCounts: aggregateSources,
203
+ topics,
204
+ topicsTruncated: inventory.topicsTruncated || sortedTopics.length > topics.length,
205
+ latestDocumentUpdatedAt: inventory.latestUpdatedAt,
206
+ memorySample: {
207
+ recordCount: memories.length,
208
+ sampleLimit: WORKSPACE_STATE_MEMORY_SAMPLE_LIMIT,
209
+ limitReached: memoryLimitReached,
210
+ statusCounts: memoryStatuses,
211
+ kindCounts: memoryKinds,
212
+ preferenceAuthority: {
213
+ kindCountSource: "knowledge_memories_legacy_observations" as const,
214
+ activeAuthority: "structured_preference_registry" as const,
215
+ },
216
+ latestUpdatedAt: newestTimestamp(memories.map((memory) => memory.updatedAt)),
217
+ },
218
+ gaps,
219
+ };
220
+ }
221
+
222
+ export function projectWorkspaceState(
223
+ input: WorkspaceStateProjectionInput,
224
+ ): WorkspaceStateResponseType {
225
+ return WorkspaceStateResponse.parse({
226
+ workspaceId: input.workspaceId,
227
+ generatedAt: input.generatedAt,
228
+ truth: {
229
+ current: { source: "read_time_projection", capturedAt: input.generatedAt },
230
+ policySnapshot: {
231
+ status: "not_captured",
232
+ reason: "workspace_instruction_policy_snapshot_not_implemented",
233
+ },
234
+ },
235
+ policy: policyProjection(input),
236
+ knowledge: input.knowledge
237
+ ? availableKnowledgeProjection(input.knowledge)
238
+ : {
239
+ availability: "unavailable",
240
+ reason: "missing_permission",
241
+ requiredPermission: "documents:search",
242
+ },
243
+ });
244
+ }