@opengeni/core 0.28.0 → 1.0.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.
- package/README.md +1 -1
- package/dist/access/external-actor-authority.d.ts +47 -0
- package/dist/access/index.d.ts +182 -3
- package/dist/access/service-initiator.d.ts +3 -0
- package/dist/application/api-integration-servers.d.ts +20 -0
- package/dist/application/archived-session-imports.d.ts +6 -0
- package/dist/application/composer-submit.d.ts +24 -0
- package/dist/application/connect-authority.d.ts +13 -0
- package/dist/application/connect-operation.d.ts +31 -0
- package/dist/application/external-continuation.d.ts +15 -0
- package/dist/application/external-identity-lifecycle.d.ts +20 -0
- package/dist/application/external-link-work-admission.d.ts +12 -0
- package/dist/application/external-workspace-members.d.ts +34 -0
- package/dist/application/modal-native-worker-host-transport.d.ts +297 -0
- package/dist/application/new-session-drafts.d.ts +39 -2
- package/dist/application/sandbox-recovery.d.ts +58 -0
- package/dist/application/session-commands.d.ts +26 -13
- package/dist/application/session-mcp-credential-rotation.d.ts +29 -0
- package/dist/application/session-tenancy.d.ts +19 -0
- package/dist/application/user-resource-grants.d.ts +20 -0
- package/dist/billing/limits.d.ts +11 -0
- package/dist/canonical-human-identities.d.ts +7 -2
- package/dist/canonical-human-identities.js +17 -4
- package/dist/canonical-human-identities.js.map +1 -1
- package/dist/{chunk-6WKPWE5S.js → chunk-2MKMRGSY.js} +108 -24
- package/dist/chunk-2MKMRGSY.js.map +1 -0
- package/dist/{chunk-NYPMQ7NO.js → chunk-GVO3N37G.js} +205 -37
- package/dist/chunk-GVO3N37G.js.map +1 -0
- package/dist/{chunk-JJU6I5XD.js → chunk-LCQXNR62.js} +36 -10
- package/dist/chunk-LCQXNR62.js.map +1 -0
- package/dist/chunk-MM6N7CTO.js +387 -0
- package/dist/chunk-MM6N7CTO.js.map +1 -0
- package/dist/chunk-QO5GVFFO.js +235 -0
- package/dist/chunk-QO5GVFFO.js.map +1 -0
- package/dist/codex-model-availability.d.ts +14 -0
- package/dist/default-session-model.d.ts +139 -0
- package/dist/dependencies.d.ts +87 -4
- package/dist/developer-setup-credential-ceiling.d.ts +10 -0
- package/dist/domain/agent-config-resolution.d.ts +49 -0
- package/dist/domain/automations.d.ts +58 -0
- package/dist/domain/capabilities.d.ts +22 -4
- package/dist/domain/company-profile-agent-admin.d.ts +25 -0
- package/dist/domain/connection-account-selection-error.d.ts +8 -0
- package/dist/domain/connector-tool-permissions.d.ts +36 -0
- package/dist/domain/direct-model-provider.d.ts +3 -0
- package/dist/domain/editable-artifacts/agent-application.d.ts +10 -1
- package/dist/domain/editable-artifacts/durable-export.d.ts +22 -1
- package/dist/domain/editable-artifacts/ports.d.ts +10 -0
- package/dist/domain/editable-artifacts/service.d.ts +4 -3
- package/dist/domain/environments.d.ts +5 -9
- package/dist/domain/external-creation-attribution.d.ts +6 -0
- package/dist/domain/file-owner.d.ts +8 -0
- package/dist/domain/github-action-policies.d.ts +36 -0
- package/dist/domain/github-repository-bindings.d.ts +117 -0
- package/dist/domain/github-skill-source.d.ts +5 -0
- package/dist/domain/governed-learning-slack-publication.d.ts +45 -0
- package/dist/domain/insights-timing.d.ts +16 -0
- package/dist/domain/insights-usage.d.ts +27 -0
- package/dist/domain/insights.d.ts +28 -1
- package/dist/domain/knowledge-files.d.ts +20 -0
- package/dist/domain/knowledge-messages.d.ts +39 -0
- package/dist/domain/knowledge-messages.test.d.ts +1 -0
- package/dist/domain/knowledge-preparation.d.ts +10 -0
- package/dist/domain/knowledge-preparation.test.d.ts +1 -0
- package/dist/domain/knowledge-search.d.ts +15 -0
- package/dist/domain/knowledge-search.test.d.ts +1 -0
- package/dist/domain/knowledge.d.ts +9 -0
- package/dist/domain/mcp-account-bindings.d.ts +19 -0
- package/dist/domain/mcp-account-routes.d.ts +21 -0
- package/dist/domain/native-mcp-connection-admission.d.ts +3 -0
- package/dist/domain/organization-integration-catalog.d.ts +15 -0
- package/dist/domain/organization-membership-lifecycle.d.ts +1 -0
- package/dist/domain/personal-connection-delegations.d.ts +87 -4
- package/dist/domain/pr-review.d.ts +249 -0
- package/dist/domain/resources.d.ts +24 -3
- package/dist/domain/scheduled-task-access.d.ts +247 -0
- package/dist/domain/scheduled-tasks.d.ts +103 -6
- package/dist/domain/session-connection-accounts.d.ts +16 -0
- package/dist/domain/session-file-access.d.ts +4 -0
- package/dist/domain/session-native-connection-replacement.d.ts +14 -0
- package/dist/domain/session-start-timing.d.ts +7 -0
- package/dist/domain/session-tool-policy.d.ts +93 -4
- package/dist/domain/sessions.d.ts +275 -28
- package/dist/domain/skill-imports.d.ts +1 -0
- package/dist/domain/skill-search.d.ts +30 -0
- package/dist/domain/skills.d.ts +117 -0
- package/dist/domain/slack-bot.d.ts +46 -1
- package/dist/domain/workspace-members.d.ts +8 -1
- package/dist/editable-artifact-live/application.d.ts +12 -3
- package/dist/editable-artifact-live/ports.d.ts +0 -1
- package/dist/editable-artifact-live/server.d.ts +5 -2
- package/dist/editable-artifact-live/ticket.d.ts +2 -2
- package/dist/editable-artifact-live/types.d.ts +14 -2
- package/dist/editable-artifact-live.js +2 -2
- package/dist/editable-artifacts.js +10 -4
- package/dist/index.d.ts +49 -3
- package/dist/index.js +18826 -6847
- package/dist/index.js.map +1 -1
- package/dist/managed-auth-session-sets.d.ts +131 -0
- package/dist/managed-auth-session-sets.js +51 -0
- package/dist/managed-auth-session-sets.js.map +1 -0
- package/dist/managed-session.d.ts +98 -11
- package/dist/model-catalog.d.ts +117 -0
- package/dist/product-usage-metrics.d.ts +30 -0
- package/dist/rigs/index.d.ts +22 -5
- package/dist/rigs/provider-images.d.ts +1 -1
- package/dist/sandbox/fleet.d.ts +59 -10
- package/dist/sandbox/routing.d.ts +22 -2
- package/dist/sandbox/runtime-settings.d.ts +56 -0
- package/dist/session-authorization.d.ts +38 -1
- package/dist/site-session-origin.d.ts +9 -0
- package/dist/transcription.d.ts +12 -1
- package/dist/turn-surface.d.ts +18 -0
- package/dist/user-presence.d.ts +32 -0
- package/package.json +18 -11
- package/src/access/external-actor-authority.ts +94 -0
- package/src/access/index.ts +1386 -44
- package/src/access/service-initiator.ts +50 -0
- package/src/application/api-integration-servers.ts +215 -0
- package/src/application/archived-session-imports.ts +161 -0
- package/src/application/composer-submit.ts +77 -0
- package/src/application/connect-authority.ts +77 -0
- package/src/application/connect-operation.ts +80 -0
- package/src/application/external-continuation.ts +129 -0
- package/src/application/external-identity-lifecycle.ts +48 -0
- package/src/application/external-link-work-admission.ts +90 -0
- package/src/application/external-workspace-members.ts +232 -0
- package/src/application/modal-native-worker-host-transport.ts +263 -0
- package/src/application/new-session-drafts.ts +222 -29
- package/src/application/sandbox-recovery.ts +78 -0
- package/src/application/session-commands.ts +430 -73
- package/src/application/session-mcp-credential-rotation.ts +217 -0
- package/src/application/session-tenancy.ts +445 -0
- package/src/application/user-resource-grants.ts +154 -0
- package/src/billing/limits.ts +103 -24
- package/src/canonical-human-identities.ts +22 -3
- package/src/codex-model-availability.ts +136 -0
- package/src/default-session-model.ts +656 -0
- package/src/dependencies.ts +97 -5
- package/src/developer-setup-credential-ceiling.ts +60 -0
- package/src/domain/agent-config-resolution.ts +148 -0
- package/src/domain/automations.ts +270 -0
- package/src/domain/capabilities.ts +244 -151
- package/src/domain/company-profile-agent-admin.ts +56 -0
- package/src/domain/connection-account-selection-error.ts +19 -0
- package/src/domain/connector-tool-permissions.ts +371 -0
- package/src/domain/direct-model-provider.ts +62 -0
- package/src/domain/editable-artifacts/agent-application.ts +61 -15
- package/src/domain/editable-artifacts/durable-export.ts +100 -5
- package/src/domain/editable-artifacts/in-memory.ts +6 -0
- package/src/domain/editable-artifacts/ports.ts +11 -0
- package/src/domain/editable-artifacts/service.ts +32 -1
- package/src/domain/editable-artifacts/snapshot-compaction.ts +9 -5
- package/src/domain/editable-artifacts/snapshot-verifier-genesis.ts +10 -8
- package/src/domain/editable-artifacts/snapshot-verifier.ts +10 -3
- package/src/domain/environments.ts +25 -45
- package/src/domain/external-creation-attribution.ts +22 -0
- package/src/domain/file-owner.ts +67 -0
- package/src/domain/github-action-policies.ts +169 -0
- package/src/domain/github-repository-bindings.ts +295 -0
- package/src/domain/github-skill-source.ts +233 -0
- package/src/domain/governed-learning-slack-publication.ts +157 -0
- package/src/domain/insights-timing.ts +95 -0
- package/src/domain/insights-usage.ts +67 -0
- package/src/domain/insights.ts +270 -169
- package/src/domain/knowledge-files.ts +46 -0
- package/src/domain/knowledge-messages.test.ts +91 -0
- package/src/domain/knowledge-messages.ts +106 -0
- package/src/domain/knowledge-preparation.test.ts +276 -0
- package/src/domain/knowledge-preparation.ts +147 -0
- package/src/domain/knowledge-search.test.ts +561 -0
- package/src/domain/knowledge-search.ts +256 -0
- package/src/domain/knowledge.ts +133 -0
- package/src/domain/mcp-account-bindings.ts +171 -0
- package/src/domain/mcp-account-routes.ts +121 -0
- package/src/domain/native-mcp-connection-admission.ts +14 -0
- package/src/domain/organization-integration-catalog.ts +88 -0
- package/src/domain/organization-membership-lifecycle.ts +10 -0
- package/src/domain/personal-connection-delegations.ts +758 -115
- package/src/domain/pr-review.ts +687 -0
- package/src/domain/resources.ts +182 -25
- package/src/domain/scheduled-task-access.ts +1052 -0
- package/src/domain/scheduled-tasks.ts +1952 -215
- package/src/domain/session-connection-accounts.ts +67 -0
- package/src/domain/session-file-access.ts +47 -0
- package/src/domain/session-native-connection-replacement.ts +64 -0
- package/src/domain/session-start-timing.ts +55 -0
- package/src/domain/session-tool-policy.ts +443 -19
- package/src/domain/sessions.ts +3417 -619
- package/src/domain/skill-imports.ts +137 -17
- package/src/domain/skill-search.ts +221 -0
- package/src/domain/skills.ts +104 -0
- package/src/domain/slack-bot.ts +128 -3
- package/src/domain/workspace-members.ts +34 -2
- package/src/editable-artifact-live/application.ts +29 -4
- package/src/editable-artifact-live/ports.ts +0 -1
- package/src/editable-artifact-live/server.ts +167 -13
- package/src/editable-artifact-live/ticket.ts +87 -6
- package/src/editable-artifact-live/types.ts +16 -2
- package/src/editable-artifact-live/wire.ts +0 -10
- package/src/index.ts +69 -4
- package/src/managed-auth-session-sets.ts +371 -0
- package/src/managed-session.ts +520 -2
- package/src/model-catalog.ts +873 -0
- package/src/product-usage-metrics.ts +101 -0
- package/src/rigs/index.ts +179 -63
- package/src/rigs/provider-images.ts +2 -3
- package/src/sandbox/fleet.ts +471 -59
- package/src/sandbox/routing.ts +160 -33
- package/src/sandbox/runtime-settings.ts +281 -0
- package/src/session-authorization.ts +262 -78
- package/src/site-session-origin.ts +22 -0
- package/src/transcription.ts +13 -1
- package/src/turn-surface.ts +45 -0
- package/src/user-presence.ts +134 -0
- package/dist/chunk-6WKPWE5S.js.map +0 -1
- package/dist/chunk-IBOEYG6N.js +0 -34
- package/dist/chunk-IBOEYG6N.js.map +0 -1
- package/dist/chunk-JJU6I5XD.js.map +0 -1
- package/dist/chunk-NYPMQ7NO.js.map +0 -1
- package/dist/domain/durable-learning-slack-publication.d.ts +0 -72
- package/dist/domain/packs.d.ts +0 -55
- package/src/domain/durable-learning-slack-publication.ts +0 -202
- package/src/domain/packs.ts +0 -556
|
@@ -0,0 +1,91 @@
|
|
|
1
|
+
import { afterAll, beforeEach, expect, mock, test } from "bun:test";
|
|
2
|
+
const actor = {
|
|
3
|
+
kind: "agent",
|
|
4
|
+
sessionId: "session-a",
|
|
5
|
+
turnId: "turn-a",
|
|
6
|
+
attemptId: "attempt-a",
|
|
7
|
+
executionGeneration: 1,
|
|
8
|
+
} as const;
|
|
9
|
+
const context = { accountId: "account", workspaceId: "workspace", actor };
|
|
10
|
+
let event: any;
|
|
11
|
+
let existing: any;
|
|
12
|
+
const policy = mock(async () => ({ defaultScope: "personal", subjectId: "user-a" }));
|
|
13
|
+
const save = mock(async (_db: any, _context: any, request: any) => ({
|
|
14
|
+
entryId: request.entryId,
|
|
15
|
+
revisionId: "revision",
|
|
16
|
+
outcome: "pending",
|
|
17
|
+
}));
|
|
18
|
+
mock.module("@opengeni/db", () => ({
|
|
19
|
+
freezeAgentLearningPolicy: policy,
|
|
20
|
+
nestedPostgresSqlState: (error: any) => error.code,
|
|
21
|
+
getSessionTurn: async () => ({ sessionId: "session-a", triggerEventId: "message-a" }),
|
|
22
|
+
getSessionEvent: async () => event,
|
|
23
|
+
getKnowledgeEntry: async () => existing,
|
|
24
|
+
saveKnowledgeEntry: save,
|
|
25
|
+
}));
|
|
26
|
+
const { retainKnowledgeMessage } = await import("./knowledge-messages");
|
|
27
|
+
afterAll(() => mock.restore());
|
|
28
|
+
beforeEach(() => {
|
|
29
|
+
event = {
|
|
30
|
+
id: "message-a",
|
|
31
|
+
sessionId: "session-a",
|
|
32
|
+
type: "user.message",
|
|
33
|
+
payload: { text: "I confirm it is 21k.\nKeep this exact." },
|
|
34
|
+
occurredAt: "2026-09-11T12:00:00Z",
|
|
35
|
+
};
|
|
36
|
+
existing = null;
|
|
37
|
+
save.mockClear();
|
|
38
|
+
});
|
|
39
|
+
test("retains exact text with message identity and preserves the review receipt", async () => {
|
|
40
|
+
const result = await retainKnowledgeMessage({} as never, context);
|
|
41
|
+
expect(result).toMatchObject({ retained: true, outcome: "pending", messageId: "message-a" });
|
|
42
|
+
expect(save.mock.calls[0]?.[2].entry).toMatchObject({
|
|
43
|
+
content: event.payload.text,
|
|
44
|
+
source: {
|
|
45
|
+
kind: "conversation",
|
|
46
|
+
sessionId: "session-a",
|
|
47
|
+
externalId: "message-a",
|
|
48
|
+
capturedAt: event.occurredAt,
|
|
49
|
+
},
|
|
50
|
+
});
|
|
51
|
+
expect(save.mock.calls[0]?.[2].scope).toBeUndefined();
|
|
52
|
+
});
|
|
53
|
+
test("refuses foreign-session and non-user events without writing", async () => {
|
|
54
|
+
event.sessionId = "private-foreign";
|
|
55
|
+
expect(await retainKnowledgeMessage({} as never, context, "foreign-event")).toMatchObject({
|
|
56
|
+
retained: false,
|
|
57
|
+
});
|
|
58
|
+
event.sessionId = "session-a";
|
|
59
|
+
event.type = "assistant.message";
|
|
60
|
+
expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({ retained: false });
|
|
61
|
+
expect(save).not.toHaveBeenCalled();
|
|
62
|
+
});
|
|
63
|
+
test("reuses a retained message without creating another review request", async () => {
|
|
64
|
+
existing = {
|
|
65
|
+
archived: false,
|
|
66
|
+
revision: {
|
|
67
|
+
id: "already-retained",
|
|
68
|
+
outcome: "pending",
|
|
69
|
+
entry: { content: event.payload.text },
|
|
70
|
+
},
|
|
71
|
+
};
|
|
72
|
+
expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({
|
|
73
|
+
retained: true,
|
|
74
|
+
reused: true,
|
|
75
|
+
revisionId: "already-retained",
|
|
76
|
+
outcome: "pending",
|
|
77
|
+
});
|
|
78
|
+
expect(save).not.toHaveBeenCalled();
|
|
79
|
+
});
|
|
80
|
+
test("never revives rejected, archived, or edited retained content", async () => {
|
|
81
|
+
for (const revision of [
|
|
82
|
+
{ outcome: "rejected", content: event.payload.text },
|
|
83
|
+
{ outcome: "published", content: "edited" },
|
|
84
|
+
]) {
|
|
85
|
+
existing = { archived: false, revision: { ...revision, entry: { content: revision.content } } };
|
|
86
|
+
expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({ retained: false });
|
|
87
|
+
}
|
|
88
|
+
existing.archived = true;
|
|
89
|
+
expect(await retainKnowledgeMessage({} as never, context)).toMatchObject({ retained: false });
|
|
90
|
+
expect(save).not.toHaveBeenCalled();
|
|
91
|
+
});
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
import { createHash } from "node:crypto";
|
|
2
|
+
import {
|
|
3
|
+
freezeAgentLearningPolicy,
|
|
4
|
+
getKnowledgeEntry,
|
|
5
|
+
getSessionEvent,
|
|
6
|
+
getSessionTurn,
|
|
7
|
+
nestedPostgresSqlState,
|
|
8
|
+
saveKnowledgeEntry,
|
|
9
|
+
type Database,
|
|
10
|
+
type KnowledgeContext,
|
|
11
|
+
} from "@opengeni/db";
|
|
12
|
+
|
|
13
|
+
function stableId(value: string) {
|
|
14
|
+
const hex = createHash("sha256").update(value).digest("hex");
|
|
15
|
+
return `${hex.slice(0, 8)}-${hex.slice(8, 12)}-5${hex.slice(13, 16)}-a${hex.slice(17, 20)}-${hex.slice(20, 32)}`;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/** Retain actual message bytes, never a model-authored reconstruction of a confirmation. */
|
|
19
|
+
export async function retainKnowledgeMessage(
|
|
20
|
+
db: Database,
|
|
21
|
+
context: KnowledgeContext,
|
|
22
|
+
messageId?: string,
|
|
23
|
+
) {
|
|
24
|
+
if (context.actor.kind !== "agent") throw new Error("An exact agent attempt is required");
|
|
25
|
+
// Validates the live attempt and resolves its frozen personal/shared authoring scope.
|
|
26
|
+
const policy = await freezeAgentLearningPolicy(db, context);
|
|
27
|
+
const turn = await getSessionTurn(db, context.workspaceId, context.actor.turnId);
|
|
28
|
+
if (!turn || turn.sessionId !== context.actor.sessionId) throw new Error("Message unavailable");
|
|
29
|
+
const eventId = messageId ?? turn.triggerEventId;
|
|
30
|
+
const event = eventId ? await getSessionEvent(db, context.workspaceId, eventId) : null;
|
|
31
|
+
const payload = event?.payload;
|
|
32
|
+
const text =
|
|
33
|
+
payload && typeof payload === "object" && "text" in payload ? payload.text : undefined;
|
|
34
|
+
if (
|
|
35
|
+
!event ||
|
|
36
|
+
event.sessionId !== context.actor.sessionId ||
|
|
37
|
+
event.type !== "user.message" ||
|
|
38
|
+
typeof text !== "string" ||
|
|
39
|
+
!text.trim()
|
|
40
|
+
) {
|
|
41
|
+
return {
|
|
42
|
+
retained: false,
|
|
43
|
+
reason:
|
|
44
|
+
"Choose a user message from this conversation. The current turn may have been triggered by a tool or scheduled event.",
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const key = `${context.accountId}:${context.workspaceId}:${policy.defaultScope}:${policy.subjectId ?? ""}:${event.id}`;
|
|
48
|
+
const entryId = stableId(`knowledge-message:${key}`);
|
|
49
|
+
for (const view of ["published", "needs_review"] as const) {
|
|
50
|
+
const existing = await getKnowledgeEntry(db, context, entryId, { view });
|
|
51
|
+
if (!existing) continue;
|
|
52
|
+
if (
|
|
53
|
+
existing.archived ||
|
|
54
|
+
existing.revision.outcome === "rejected" ||
|
|
55
|
+
existing.revision.entry.content !== text
|
|
56
|
+
) {
|
|
57
|
+
return {
|
|
58
|
+
retained: false,
|
|
59
|
+
reason:
|
|
60
|
+
"This retained message was edited, archived or rejected. Inspect its history; it has not been overwritten or revived.",
|
|
61
|
+
entryId,
|
|
62
|
+
};
|
|
63
|
+
}
|
|
64
|
+
return {
|
|
65
|
+
retained: true,
|
|
66
|
+
entryId,
|
|
67
|
+
revisionId: existing.revision.id,
|
|
68
|
+
outcome: existing.revision.outcome,
|
|
69
|
+
messageId: event.id,
|
|
70
|
+
reused: true,
|
|
71
|
+
};
|
|
72
|
+
}
|
|
73
|
+
let receipt;
|
|
74
|
+
try {
|
|
75
|
+
receipt = await saveKnowledgeEntry(db, context, {
|
|
76
|
+
operationId: crypto.randomUUID(),
|
|
77
|
+
entryId,
|
|
78
|
+
expectedVersion: 0,
|
|
79
|
+
entry: {
|
|
80
|
+
kind: "source",
|
|
81
|
+
title: `Chat: ${text.replace(/\s+/g, " ").slice(0, 100)}`,
|
|
82
|
+
content: text,
|
|
83
|
+
source: {
|
|
84
|
+
kind: "conversation",
|
|
85
|
+
purpose: "evidence",
|
|
86
|
+
sessionId: event.sessionId,
|
|
87
|
+
externalId: event.id,
|
|
88
|
+
capturedAt: event.occurredAt,
|
|
89
|
+
retention: "full_text",
|
|
90
|
+
},
|
|
91
|
+
},
|
|
92
|
+
});
|
|
93
|
+
} catch (error) {
|
|
94
|
+
// An inaccessible archived/rejected source or a concurrent creation must not
|
|
95
|
+
// be overwritten. The stable entry ID makes retries converge without a new copy.
|
|
96
|
+
if (nestedPostgresSqlState(error) === "40001")
|
|
97
|
+
return {
|
|
98
|
+
retained: false,
|
|
99
|
+
entryId,
|
|
100
|
+
reason:
|
|
101
|
+
"This message already has a retained source or changed concurrently. Inspect available Knowledge before retrying; no source was overwritten or revived.",
|
|
102
|
+
};
|
|
103
|
+
throw error;
|
|
104
|
+
}
|
|
105
|
+
return { retained: true, ...receipt, messageId: event.id };
|
|
106
|
+
}
|
|
@@ -0,0 +1,276 @@
|
|
|
1
|
+
import { expect, test } from "bun:test";
|
|
2
|
+
import type { KnowledgeEntryRecord, KnowledgeEntrySummary } from "@opengeni/contracts";
|
|
3
|
+
import { prepareKnowledgeSave } from "./knowledge-preparation";
|
|
4
|
+
|
|
5
|
+
const context = {
|
|
6
|
+
accountId: "account",
|
|
7
|
+
workspaceId: "workspace",
|
|
8
|
+
actor: { kind: "human", review: true },
|
|
9
|
+
} as never;
|
|
10
|
+
const db = {} as never;
|
|
11
|
+
const embedder = () => {
|
|
12
|
+
throw new Error("not used by fake search");
|
|
13
|
+
};
|
|
14
|
+
function group(title: string, parentIds: string[] = []): KnowledgeEntryRecord {
|
|
15
|
+
const id = crypto.randomUUID();
|
|
16
|
+
return {
|
|
17
|
+
id,
|
|
18
|
+
scope: "workspace",
|
|
19
|
+
version: 1,
|
|
20
|
+
publishedRevisionId: id,
|
|
21
|
+
latestRevisionId: id,
|
|
22
|
+
archived: false,
|
|
23
|
+
createdAt: "2026-09-14T00:00:00Z",
|
|
24
|
+
updatedAt: "2026-09-14T00:00:00Z",
|
|
25
|
+
revision: {
|
|
26
|
+
id,
|
|
27
|
+
entryId: id,
|
|
28
|
+
number: 1,
|
|
29
|
+
change: "upsert",
|
|
30
|
+
previousRevisionId: null,
|
|
31
|
+
restoredFromRevisionId: null,
|
|
32
|
+
createdAt: "2026-09-14T00:00:00Z",
|
|
33
|
+
createdBySessionId: null,
|
|
34
|
+
reviewBatchId: null,
|
|
35
|
+
outcome: "published",
|
|
36
|
+
entry: {
|
|
37
|
+
title,
|
|
38
|
+
kind: "group",
|
|
39
|
+
content: `Description of ${title}`,
|
|
40
|
+
groupIds: parentIds,
|
|
41
|
+
evidence: [],
|
|
42
|
+
relationships: [],
|
|
43
|
+
},
|
|
44
|
+
},
|
|
45
|
+
};
|
|
46
|
+
}
|
|
47
|
+
const summary = (record: KnowledgeEntryRecord) =>
|
|
48
|
+
({
|
|
49
|
+
...record,
|
|
50
|
+
revision: {
|
|
51
|
+
...record.revision,
|
|
52
|
+
title: record.revision.entry.title,
|
|
53
|
+
kind: "group",
|
|
54
|
+
preview: "preview",
|
|
55
|
+
groupIds: record.revision.entry.groupIds,
|
|
56
|
+
sourceKind: null,
|
|
57
|
+
},
|
|
58
|
+
excerpts: [],
|
|
59
|
+
}) as KnowledgeEntrySummary;
|
|
60
|
+
|
|
61
|
+
test("one read returns the complete paginated hierarchy and separately labeled duplicate candidates", async () => {
|
|
62
|
+
const parent = group("Engineering");
|
|
63
|
+
const child = group("Product UI", [parent.id]);
|
|
64
|
+
const pending = group("Proposed collection");
|
|
65
|
+
pending.revision.outcome = "pending";
|
|
66
|
+
const revoked = group("Revoked");
|
|
67
|
+
const calls: unknown[] = [];
|
|
68
|
+
const result = await prepareKnowledgeSave(
|
|
69
|
+
db,
|
|
70
|
+
context,
|
|
71
|
+
{ query: "plugin setup consistency" },
|
|
72
|
+
embedder,
|
|
73
|
+
{
|
|
74
|
+
list: async (actualDb, actualContext, request) => {
|
|
75
|
+
expect(actualDb).toBe(db);
|
|
76
|
+
expect(actualContext).toBe(context);
|
|
77
|
+
calls.push(request);
|
|
78
|
+
if (request?.view === "needs_review")
|
|
79
|
+
return { entries: [summary(pending)], nextCursor: null };
|
|
80
|
+
return request?.cursor
|
|
81
|
+
? { entries: [summary(child), summary(revoked)], nextCursor: null }
|
|
82
|
+
: { entries: [summary(parent)], nextCursor: "page2" };
|
|
83
|
+
},
|
|
84
|
+
get: async (_db, _context, id, options) => {
|
|
85
|
+
expect(options?.revisionId).toBeUndefined();
|
|
86
|
+
return [parent, child, pending].find((record) => record.id === id) ?? null;
|
|
87
|
+
},
|
|
88
|
+
search: async (_db, actualContext, request) => {
|
|
89
|
+
expect(actualContext).toBe(context);
|
|
90
|
+
expect(request.query).toBe("plugin setup consistency");
|
|
91
|
+
expect(request.groupId).toBeUndefined();
|
|
92
|
+
expect(request.scope).toBeUndefined();
|
|
93
|
+
return {
|
|
94
|
+
entries: [summary(request.view === "needs_review" ? pending : child)],
|
|
95
|
+
nextCursor: null,
|
|
96
|
+
searchMode: "hybrid",
|
|
97
|
+
};
|
|
98
|
+
},
|
|
99
|
+
},
|
|
100
|
+
);
|
|
101
|
+
expect(calls).toHaveLength(3);
|
|
102
|
+
expect(result.collections.complete).toBe(true);
|
|
103
|
+
expect(result.collections.entries.map((entry) => entry.title)).toEqual([
|
|
104
|
+
"Engineering",
|
|
105
|
+
"Product UI",
|
|
106
|
+
"Proposed collection",
|
|
107
|
+
]);
|
|
108
|
+
expect(result.collections.entries[1]?.parentIds).toEqual([parent.id]);
|
|
109
|
+
expect(result.collections.entries[0]?.description).toBe("Description of Engineering");
|
|
110
|
+
expect(result.collections.entries[2]?.view).toBe("needs_review");
|
|
111
|
+
expect(result.matches.published.entries[0]?.id).toBe(child.id);
|
|
112
|
+
expect(result.matches.needs_review.entries[0]?.id).toBe(pending.id);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
test("large collection maps explicitly continue and long descriptions report loss", async () => {
|
|
116
|
+
const record = group("Large collection");
|
|
117
|
+
record.revision.entry.content = "x".repeat(3000);
|
|
118
|
+
let count = 0;
|
|
119
|
+
const services: NonNullable<Parameters<typeof prepareKnowledgeSave>[4]> = {
|
|
120
|
+
list: async (_db, _context, request) => {
|
|
121
|
+
if (request?.view === "needs_review") return { entries: [], nextCursor: null };
|
|
122
|
+
return { entries: [summary(record)], nextCursor: `page${++count}` };
|
|
123
|
+
},
|
|
124
|
+
get: async () => record,
|
|
125
|
+
search: async () => ({ entries: [], nextCursor: null, searchMode: "keyword" }),
|
|
126
|
+
};
|
|
127
|
+
const result = await prepareKnowledgeSave(db, context, { query: "subject" }, embedder, services);
|
|
128
|
+
expect(result.collections.complete).toBe(false);
|
|
129
|
+
expect(result.collections.nextCursors.published).toBeTruthy();
|
|
130
|
+
expect(result.collections.nextCursors.needs_review).toBeNull();
|
|
131
|
+
expect(result.collections.entries[0]?.descriptionTruncated).toBe(true);
|
|
132
|
+
expect(result.collections.entries[0]?.description).toHaveLength(2000);
|
|
133
|
+
let requestedCursor: string | undefined;
|
|
134
|
+
await prepareKnowledgeSave(
|
|
135
|
+
db,
|
|
136
|
+
context,
|
|
137
|
+
{ query: "subject", collectionCursors: result.collections.nextCursors },
|
|
138
|
+
embedder,
|
|
139
|
+
{
|
|
140
|
+
...services,
|
|
141
|
+
list: async (_db, _context, request) => {
|
|
142
|
+
expect(request?.view).toBe("published");
|
|
143
|
+
requestedCursor = request?.cursor;
|
|
144
|
+
return { entries: [], nextCursor: null };
|
|
145
|
+
},
|
|
146
|
+
},
|
|
147
|
+
);
|
|
148
|
+
expect(requestedCursor).toBe(result.collections.nextCursors.published!);
|
|
149
|
+
});
|
|
150
|
+
|
|
151
|
+
test("a catalog authorization failure is not disguised as an empty complete map", async () => {
|
|
152
|
+
await expect(
|
|
153
|
+
prepareKnowledgeSave(db, context, { query: "subject" }, embedder, {
|
|
154
|
+
list: async () => {
|
|
155
|
+
throw new Error("access revoked");
|
|
156
|
+
},
|
|
157
|
+
get: async () => null,
|
|
158
|
+
search: async () => ({ entries: [], nextCursor: null, searchMode: "keyword" }),
|
|
159
|
+
}),
|
|
160
|
+
).rejects.toThrow("access revoked");
|
|
161
|
+
});
|
|
162
|
+
|
|
163
|
+
test("read-only principals without proposal access fail before searching or embedding", async () => {
|
|
164
|
+
let touched = false;
|
|
165
|
+
await expect(
|
|
166
|
+
prepareKnowledgeSave(
|
|
167
|
+
db,
|
|
168
|
+
{
|
|
169
|
+
accountId: "account",
|
|
170
|
+
workspaceId: "workspace",
|
|
171
|
+
actor: { kind: "service", review: false },
|
|
172
|
+
} as never,
|
|
173
|
+
{ query: "subject" },
|
|
174
|
+
() => {
|
|
175
|
+
touched = true;
|
|
176
|
+
throw new Error("must not embed");
|
|
177
|
+
},
|
|
178
|
+
{
|
|
179
|
+
list: async () => {
|
|
180
|
+
touched = true;
|
|
181
|
+
return { entries: [], nextCursor: null };
|
|
182
|
+
},
|
|
183
|
+
get: async () => {
|
|
184
|
+
touched = true;
|
|
185
|
+
return null;
|
|
186
|
+
},
|
|
187
|
+
search: async () => {
|
|
188
|
+
touched = true;
|
|
189
|
+
return { entries: [], nextCursor: null, searchMode: "keyword" };
|
|
190
|
+
},
|
|
191
|
+
},
|
|
192
|
+
),
|
|
193
|
+
).rejects.toMatchObject({ code: "42501" });
|
|
194
|
+
expect(touched).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
|
|
197
|
+
test("published and pending searches reuse one query embedding", async () => {
|
|
198
|
+
let calls = 0;
|
|
199
|
+
await prepareKnowledgeSave(
|
|
200
|
+
db,
|
|
201
|
+
context,
|
|
202
|
+
{ query: "plugin dialog" },
|
|
203
|
+
() => ({
|
|
204
|
+
model: "test",
|
|
205
|
+
dimensions: 2,
|
|
206
|
+
embedMany: async () => [],
|
|
207
|
+
embedQuery: async () => {
|
|
208
|
+
calls++;
|
|
209
|
+
return [1, 0];
|
|
210
|
+
},
|
|
211
|
+
}),
|
|
212
|
+
{
|
|
213
|
+
list: async () => ({ entries: [], nextCursor: null }),
|
|
214
|
+
get: async () => null,
|
|
215
|
+
search: async (_db, _context, request, queryEmbedder) => {
|
|
216
|
+
expect(await queryEmbedder().embedQuery(request.query!)).toEqual([1, 0]);
|
|
217
|
+
return { entries: [], nextCursor: null, searchMode: "hybrid" };
|
|
218
|
+
},
|
|
219
|
+
},
|
|
220
|
+
);
|
|
221
|
+
expect(calls).toBe(1);
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
test("a keyword fallback in the first view never retries an unfunded provider in the review view", async () => {
|
|
225
|
+
const searches: Array<{ mode: string | undefined; settingsProvided: boolean }> = [];
|
|
226
|
+
await prepareKnowledgeSave(
|
|
227
|
+
db,
|
|
228
|
+
context,
|
|
229
|
+
{ query: "Acme renewal" },
|
|
230
|
+
() => {
|
|
231
|
+
throw new Error("An unfunded provider was called");
|
|
232
|
+
},
|
|
233
|
+
{
|
|
234
|
+
list: async () => ({ entries: [], nextCursor: null }),
|
|
235
|
+
get: async () => null,
|
|
236
|
+
search: async (_db, _context, request, _embedder, settings) => {
|
|
237
|
+
searches.push({ mode: request.mode, settingsProvided: !!settings });
|
|
238
|
+
return { entries: [], nextCursor: null, searchMode: "keyword" };
|
|
239
|
+
},
|
|
240
|
+
},
|
|
241
|
+
{ documentEmbeddingProvider: "openai", documentEmbeddingBillingMode: "credits" } as never,
|
|
242
|
+
);
|
|
243
|
+
expect(searches).toEqual([
|
|
244
|
+
{ mode: "keyword", settingsProvided: false },
|
|
245
|
+
{ mode: "keyword", settingsProvided: false },
|
|
246
|
+
]);
|
|
247
|
+
});
|
|
248
|
+
|
|
249
|
+
test("catalog rechecks current metadata after a collection changes", async () => {
|
|
250
|
+
const old = group("Old name");
|
|
251
|
+
const current = {
|
|
252
|
+
...old,
|
|
253
|
+
version: 2,
|
|
254
|
+
revision: {
|
|
255
|
+
...old.revision,
|
|
256
|
+
id: crypto.randomUUID(),
|
|
257
|
+
entry: { ...old.revision.entry, title: "New name" },
|
|
258
|
+
},
|
|
259
|
+
};
|
|
260
|
+
const result = await prepareKnowledgeSave(db, context, { query: "subject" }, embedder, {
|
|
261
|
+
list: async (_db, _context, request) => ({
|
|
262
|
+
entries: request?.view === "published" ? [summary(old)] : [],
|
|
263
|
+
nextCursor: null,
|
|
264
|
+
}),
|
|
265
|
+
get: async (_db, _context, _id, options) => {
|
|
266
|
+
expect(options?.revisionId).toBeUndefined();
|
|
267
|
+
return current;
|
|
268
|
+
},
|
|
269
|
+
search: async () => ({ entries: [], nextCursor: null, searchMode: "keyword" }),
|
|
270
|
+
});
|
|
271
|
+
expect(result.collections.entries[0]).toMatchObject({
|
|
272
|
+
title: "New name",
|
|
273
|
+
version: 2,
|
|
274
|
+
revisionId: current.revision.id,
|
|
275
|
+
});
|
|
276
|
+
});
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import {
|
|
2
|
+
KnowledgeSavePreparationRequest,
|
|
3
|
+
type KnowledgeSavePreparationResponse,
|
|
4
|
+
} from "@opengeni/contracts";
|
|
5
|
+
import {
|
|
6
|
+
getKnowledgeEntry,
|
|
7
|
+
listKnowledgeEntries,
|
|
8
|
+
type Database,
|
|
9
|
+
type KnowledgeContext,
|
|
10
|
+
} from "@opengeni/db";
|
|
11
|
+
import type { DocumentEmbedder } from "@opengeni/documents";
|
|
12
|
+
import type { Settings } from "@opengeni/config";
|
|
13
|
+
import { paidDocumentEmbedding } from "../billing/limits";
|
|
14
|
+
import { searchKnowledgeEntries } from "./knowledge-search";
|
|
15
|
+
|
|
16
|
+
const defaults = {
|
|
17
|
+
get: getKnowledgeEntry,
|
|
18
|
+
list: listKnowledgeEntries,
|
|
19
|
+
search: searchKnowledgeEntries,
|
|
20
|
+
};
|
|
21
|
+
// Normal workspaces return the whole map in one call. Larger maps explicitly
|
|
22
|
+
// continue at a complete page boundary, never silently omit collections.
|
|
23
|
+
const CATALOG_PAGE_SIZE = 25;
|
|
24
|
+
const CATALOG_PAGE_BUDGET = 40;
|
|
25
|
+
const DESCRIPTION_CHARS = 2000;
|
|
26
|
+
const CATALOG_BYTES = 256 * 1024;
|
|
27
|
+
|
|
28
|
+
export async function prepareKnowledgeSave(
|
|
29
|
+
db: Database,
|
|
30
|
+
context: KnowledgeContext,
|
|
31
|
+
input: KnowledgeSavePreparationRequest,
|
|
32
|
+
embedder: () => DocumentEmbedder,
|
|
33
|
+
services = defaults,
|
|
34
|
+
settings?: Settings,
|
|
35
|
+
): Promise<KnowledgeSavePreparationResponse> {
|
|
36
|
+
const request = KnowledgeSavePreparationRequest.parse(input);
|
|
37
|
+
if (context.actor.kind !== "agent" && !(context.actor.kind === "human" && context.actor.review)) {
|
|
38
|
+
throw Object.assign(
|
|
39
|
+
new Error("Preparing Knowledge requires a live agent or human Knowledge reviewer"),
|
|
40
|
+
{ code: "42501" },
|
|
41
|
+
);
|
|
42
|
+
}
|
|
43
|
+
let provider: DocumentEmbedder | undefined;
|
|
44
|
+
let queryEmbedding: Promise<number[]> | undefined;
|
|
45
|
+
const sharedEmbedder = (): DocumentEmbedder => {
|
|
46
|
+
provider ??= embedder();
|
|
47
|
+
const current = provider;
|
|
48
|
+
return {
|
|
49
|
+
model: current.model,
|
|
50
|
+
dimensions: current.dimensions,
|
|
51
|
+
embedMany: (texts) => current.embedMany(texts),
|
|
52
|
+
embedQuery: (query) => (queryEmbedding ??= current.embedQuery(query)),
|
|
53
|
+
};
|
|
54
|
+
};
|
|
55
|
+
const collections: KnowledgeSavePreparationResponse["collections"] = {
|
|
56
|
+
entries: [],
|
|
57
|
+
complete: true,
|
|
58
|
+
nextCursors: { published: null, needs_review: null },
|
|
59
|
+
};
|
|
60
|
+
// Search is independent of collection placement and always includes the
|
|
61
|
+
// separate unapproved view. No authoring policy or write is invoked here.
|
|
62
|
+
// Preparation returns BOTH views plus a catalog. A charge on the first
|
|
63
|
+
// semantic search would precede the second view and catalog; any later
|
|
64
|
+
// failure would leave no deliverable preparation result. Paid mode uses
|
|
65
|
+
// keyword discovery until a single-operation settlement seam exists.
|
|
66
|
+
const paid = settings != null && paidDocumentEmbedding(settings);
|
|
67
|
+
const published = await services.search(
|
|
68
|
+
db,
|
|
69
|
+
context,
|
|
70
|
+
{
|
|
71
|
+
query: request.query,
|
|
72
|
+
limit: request.limit,
|
|
73
|
+
view: "published",
|
|
74
|
+
...(paid ? { mode: "keyword" as const } : {}),
|
|
75
|
+
},
|
|
76
|
+
sharedEmbedder,
|
|
77
|
+
paid ? undefined : settings,
|
|
78
|
+
);
|
|
79
|
+
// Reuse the first request's provider result (and charge). If it fell back
|
|
80
|
+
// to keyword because funding or the provider was unavailable, do not retry
|
|
81
|
+
// an unfunded embedding request in the second review view.
|
|
82
|
+
const needsReview = await services.search(
|
|
83
|
+
db,
|
|
84
|
+
context,
|
|
85
|
+
{
|
|
86
|
+
query: request.query,
|
|
87
|
+
limit: request.limit,
|
|
88
|
+
view: "needs_review",
|
|
89
|
+
...(published.searchMode === "keyword" ? { mode: "keyword" as const } : {}),
|
|
90
|
+
},
|
|
91
|
+
sharedEmbedder,
|
|
92
|
+
);
|
|
93
|
+
const matches = { published, needs_review: needsReview };
|
|
94
|
+
for (const view of ["published", "needs_review"] as const) {
|
|
95
|
+
if (request.collectionCursors && request.collectionCursors[view] === null) continue;
|
|
96
|
+
let cursor = request.collectionCursors?.[view] ?? undefined;
|
|
97
|
+
let bytes = 0;
|
|
98
|
+
for (let pageNumber = 0; pageNumber < CATALOG_PAGE_BUDGET; pageNumber++) {
|
|
99
|
+
const page = await services.list(db, context, {
|
|
100
|
+
kind: "group",
|
|
101
|
+
view,
|
|
102
|
+
limit: CATALOG_PAGE_SIZE,
|
|
103
|
+
...(cursor ? { cursor } : {}),
|
|
104
|
+
});
|
|
105
|
+
const records: Array<Awaited<ReturnType<typeof getKnowledgeEntry>>> = [];
|
|
106
|
+
for (let offset = 0; offset < page.entries.length; offset += 4) {
|
|
107
|
+
records.push(
|
|
108
|
+
...(await Promise.all(
|
|
109
|
+
page.entries
|
|
110
|
+
.slice(offset, offset + 4)
|
|
111
|
+
.map((summary) => services.get(db, context, summary.id, { view })),
|
|
112
|
+
)),
|
|
113
|
+
);
|
|
114
|
+
}
|
|
115
|
+
for (const record of records) {
|
|
116
|
+
// Re-read the current view: an exact historical revision may remain
|
|
117
|
+
// readable after replacement and must not be called the current map.
|
|
118
|
+
if (
|
|
119
|
+
!record ||
|
|
120
|
+
record.archived ||
|
|
121
|
+
record.revision.entry.kind !== "group" ||
|
|
122
|
+
record.revision.outcome !== (view === "published" ? "published" : "pending")
|
|
123
|
+
)
|
|
124
|
+
continue;
|
|
125
|
+
const entry = record.revision.entry;
|
|
126
|
+
const descriptor = {
|
|
127
|
+
id: record.id,
|
|
128
|
+
revisionId: record.revision.id,
|
|
129
|
+
version: record.version,
|
|
130
|
+
scope: record.scope,
|
|
131
|
+
view,
|
|
132
|
+
title: entry.title,
|
|
133
|
+
description: entry.content.slice(0, DESCRIPTION_CHARS),
|
|
134
|
+
descriptionTruncated: entry.content.length > DESCRIPTION_CHARS,
|
|
135
|
+
parentIds: entry.groupIds,
|
|
136
|
+
};
|
|
137
|
+
collections.entries.push(descriptor);
|
|
138
|
+
bytes += Buffer.byteLength(JSON.stringify(descriptor), "utf8");
|
|
139
|
+
}
|
|
140
|
+
cursor = page.nextCursor ?? undefined;
|
|
141
|
+
if (!cursor || bytes >= CATALOG_BYTES) break;
|
|
142
|
+
}
|
|
143
|
+
collections.nextCursors[view] = cursor ?? null;
|
|
144
|
+
if (cursor) collections.complete = false;
|
|
145
|
+
}
|
|
146
|
+
return { collections, matches };
|
|
147
|
+
}
|