mirror of
https://github.com/kennethnym/freya
synced 2026-06-23 01:44:55 +01:00
refactor: move conversation types to core (#149)
This commit is contained in:
@@ -8,7 +8,8 @@
|
||||
"test": "bun test ."
|
||||
},
|
||||
"dependencies": {
|
||||
"@standard-schema/spec": "^1.1.0"
|
||||
"@standard-schema/spec": "^1.1.0",
|
||||
"arktype": "^2.1.29"
|
||||
},
|
||||
"peerDependencies": {
|
||||
"@json-render/core": "*",
|
||||
|
||||
146
packages/freya-core/src/conversation.test.ts
Normal file
146
packages/freya-core/src/conversation.test.ts
Normal file
@@ -0,0 +1,146 @@
|
||||
import { describe, expect, test } from "bun:test"
|
||||
|
||||
import {
|
||||
AttachmentType,
|
||||
AttachmentPayload,
|
||||
ContextSummaryPayload,
|
||||
ConversationEntryMetadata,
|
||||
GenericObjectPayload,
|
||||
UserMessagePayload,
|
||||
} from "./conversation"
|
||||
|
||||
describe("conversation entry schemas", () => {
|
||||
test("parses valid user message payloads", () => {
|
||||
const payload = UserMessagePayload.assert({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
|
||||
expect(payload).toEqual({
|
||||
role: "user",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects user message payloads with the wrong role", () => {
|
||||
expect(() =>
|
||||
UserMessagePayload.assert({
|
||||
role: "assistant",
|
||||
parts: [{ type: "text", text: "hello" }],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("rejects user message payloads with no parts", () => {
|
||||
expect(() =>
|
||||
UserMessagePayload.assert({
|
||||
role: "user",
|
||||
parts: [],
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("parses valid attachment payloads", () => {
|
||||
const payload = AttachmentPayload.assert({
|
||||
role: "user",
|
||||
name: "whiteboard.png",
|
||||
mimeType: "image/png",
|
||||
attachmentType: AttachmentType.Image,
|
||||
caption: "whiteboard sketch",
|
||||
})
|
||||
|
||||
expect(payload).toEqual({
|
||||
role: "user",
|
||||
name: "whiteboard.png",
|
||||
mimeType: "image/png",
|
||||
attachmentType: AttachmentType.Image,
|
||||
caption: "whiteboard sketch",
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects extra fields on structured payloads", () => {
|
||||
expect(() =>
|
||||
AttachmentPayload.assert({
|
||||
role: "user",
|
||||
name: "whiteboard.png",
|
||||
mimeType: "image/png",
|
||||
attachmentType: AttachmentType.Image,
|
||||
fileId: "file-1",
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
|
||||
test("parses context summary payloads", () => {
|
||||
const payload = ContextSummaryPayload.assert({
|
||||
covers: {
|
||||
startSequence: 1,
|
||||
endSequence: 12,
|
||||
},
|
||||
summary: {
|
||||
userIntent: "Design message storage.",
|
||||
durableFacts: [],
|
||||
preferences: ["Keep the schema simple."],
|
||||
decisions: ["Use conversation_entries as the timeline."],
|
||||
openTasks: [],
|
||||
importantDetails: [],
|
||||
},
|
||||
promptVersion: "conversation-summary-v1",
|
||||
sourceEntryIds: ["entry-1", "entry-2"],
|
||||
})
|
||||
|
||||
expect(payload).toMatchObject({
|
||||
covers: {
|
||||
startSequence: 1,
|
||||
endSequence: 12,
|
||||
},
|
||||
promptVersion: "conversation-summary-v1",
|
||||
})
|
||||
})
|
||||
|
||||
test("allows generic object payloads for tool entries", () => {
|
||||
const payload = GenericObjectPayload.assert({
|
||||
toolCallId: "call-1",
|
||||
toolName: "calendar.search",
|
||||
input: { date: "2026-06-15" },
|
||||
})
|
||||
|
||||
expect(payload).toEqual({
|
||||
toolCallId: "call-1",
|
||||
toolName: "calendar.search",
|
||||
input: { date: "2026-06-15" },
|
||||
})
|
||||
})
|
||||
|
||||
test("rejects non-object generic payloads", () => {
|
||||
expect(() => GenericObjectPayload.assert("done")).toThrow()
|
||||
})
|
||||
|
||||
test("parses model run metadata and allows extra top-level metadata", () => {
|
||||
const metadata = ConversationEntryMetadata.assert({
|
||||
modelRun: {
|
||||
route: "default-chat",
|
||||
provider: "pi",
|
||||
model: "pi-model",
|
||||
inputTokens: 120,
|
||||
outputTokens: 24,
|
||||
},
|
||||
traceId: "trace-1",
|
||||
})
|
||||
|
||||
expect(metadata.modelRun?.model).toBe("pi-model")
|
||||
expect(metadata.traceId).toBe("trace-1")
|
||||
})
|
||||
|
||||
test("rejects invalid model run metadata", () => {
|
||||
expect(() =>
|
||||
ConversationEntryMetadata.assert({
|
||||
modelRun: {
|
||||
route: "default-chat",
|
||||
provider: "pi",
|
||||
model: "pi-model",
|
||||
inputTokens: -1,
|
||||
},
|
||||
}),
|
||||
).toThrow()
|
||||
})
|
||||
})
|
||||
161
packages/freya-core/src/conversation.ts
Normal file
161
packages/freya-core/src/conversation.ts
Normal file
@@ -0,0 +1,161 @@
|
||||
import { type } from "arktype"
|
||||
|
||||
/** Entry kinds supported by the persisted conversation timeline. */
|
||||
export const ConversationEntryKind = {
|
||||
UserMessage: "user_message",
|
||||
AssistantMessage: "assistant_message",
|
||||
Attachment: "attachment",
|
||||
ToolCall: "tool_call",
|
||||
ToolResult: "tool_result",
|
||||
ContextSummary: "context_summary",
|
||||
SystemNote: "system_note",
|
||||
} as const
|
||||
|
||||
/** Discriminator for the payload shape and handling of a conversation entry. */
|
||||
export type ConversationEntryKind =
|
||||
(typeof ConversationEntryKind)[keyof typeof ConversationEntryKind]
|
||||
|
||||
/** Visibility scopes supported by stored conversation entries. */
|
||||
export const ConversationEntryVisibility = {
|
||||
UserVisible: "user_visible",
|
||||
Internal: "internal",
|
||||
} as const
|
||||
|
||||
/** Indicates whether a conversation entry should be exposed to the user. */
|
||||
export type ConversationEntryVisibility =
|
||||
(typeof ConversationEntryVisibility)[keyof typeof ConversationEntryVisibility]
|
||||
|
||||
/** Attachment media categories accepted by conversation entries. */
|
||||
export const AttachmentType = {
|
||||
Image: "image",
|
||||
Audio: "audio",
|
||||
Video: "video",
|
||||
Document: "document",
|
||||
Other: "other",
|
||||
} as const
|
||||
|
||||
/** File or media category associated with an attachment payload. */
|
||||
export type AttachmentType = (typeof AttachmentType)[keyof typeof AttachmentType]
|
||||
|
||||
/** Plain text content part for a message. */
|
||||
export const TextMessagePart = type({
|
||||
"+": "reject",
|
||||
type: "'text'",
|
||||
text: "string",
|
||||
})
|
||||
|
||||
/** Structured JSON content part for a message. */
|
||||
export const JsonMessagePart = type({
|
||||
"+": "reject",
|
||||
type: "'json'",
|
||||
value: "unknown",
|
||||
})
|
||||
|
||||
/** Content part variants supported by user and assistant messages. */
|
||||
export const MessagePart = type.or(TextMessagePart, JsonMessagePart)
|
||||
|
||||
/** A structured content part inside a user or assistant message payload. */
|
||||
export type MessagePart = typeof MessagePart.infer
|
||||
|
||||
/** User-authored message entry payload. */
|
||||
export const UserMessagePayload = type({
|
||||
"+": "reject",
|
||||
role: "'user'",
|
||||
parts: MessagePart.array().atLeastLength(1),
|
||||
})
|
||||
|
||||
/** Payload stored for a conversation entry containing a user message. */
|
||||
export type UserMessagePayload = typeof UserMessagePayload.infer
|
||||
|
||||
/** Assistant-authored message entry payload. */
|
||||
export const AssistantMessagePayload = type({
|
||||
"+": "reject",
|
||||
role: "'assistant'",
|
||||
parts: MessagePart.array().atLeastLength(1),
|
||||
})
|
||||
|
||||
/** Payload stored for a conversation entry containing an assistant message. */
|
||||
export type AssistantMessagePayload = typeof AssistantMessagePayload.infer
|
||||
|
||||
/** Attachment entry payload. */
|
||||
export const AttachmentPayload = type({
|
||||
"+": "reject",
|
||||
role: type.enumerated("user", "assistant"),
|
||||
name: "string",
|
||||
mimeType: "string",
|
||||
attachmentType: type.enumerated(...Object.values(AttachmentType)),
|
||||
"caption?": "string",
|
||||
})
|
||||
|
||||
/** Payload stored for a conversation entry that references an uploaded file. */
|
||||
export type AttachmentPayload = typeof AttachmentPayload.infer
|
||||
|
||||
/** Durable facts extracted from compacted conversation history. */
|
||||
export const ContextSummary = type({
|
||||
"+": "reject",
|
||||
"userIntent?": "string",
|
||||
durableFacts: type.string.array(),
|
||||
preferences: type.string.array(),
|
||||
decisions: type.string.array(),
|
||||
openTasks: type.string.array(),
|
||||
importantDetails: type.string.array(),
|
||||
})
|
||||
|
||||
/** Durable facts and follow-ups retained from compacted conversation history. */
|
||||
export type ContextSummary = typeof ContextSummary.infer
|
||||
|
||||
/** Context-summary conversation entry payload. */
|
||||
export const ContextSummaryPayload = type({
|
||||
"+": "reject",
|
||||
covers: type({
|
||||
"+": "reject",
|
||||
startSequence: "number.integer >= 1",
|
||||
endSequence: "number.integer >= 1",
|
||||
}),
|
||||
summary: ContextSummary,
|
||||
promptVersion: "string",
|
||||
"sourceEntryIds?": type.string.array(),
|
||||
})
|
||||
|
||||
/** Payload describing a compaction summary and the sequence range it covers. */
|
||||
export type ContextSummaryPayload = typeof ContextSummaryPayload.infer
|
||||
|
||||
/** Model invocation metadata recorded on generated entries. */
|
||||
export const ModelRunMetadata = type({
|
||||
"+": "reject",
|
||||
route: "string",
|
||||
provider: "string",
|
||||
model: "string",
|
||||
"contextSummaryEntryId?": "string",
|
||||
"rawEntriesStartSequence?": "number.integer >= 1",
|
||||
"rawEntriesEndSequence?": "number.integer >= 1",
|
||||
"inputTokens?": "number.integer >= 0",
|
||||
"outputTokens?": "number.integer >= 0",
|
||||
"providerRequestId?": "string",
|
||||
})
|
||||
|
||||
/** Metadata describing the model run that produced a conversation entry. */
|
||||
export type ModelRunMetadata = typeof ModelRunMetadata.infer
|
||||
|
||||
/** Arbitrary metadata stored alongside conversation entries. */
|
||||
export const ConversationEntryMetadata = type({
|
||||
"modelRun?": ModelRunMetadata,
|
||||
"[string]": "unknown",
|
||||
})
|
||||
|
||||
/** Metadata bag attached to a conversation entry. */
|
||||
export type ConversationEntryMetadata = typeof ConversationEntryMetadata.infer
|
||||
|
||||
/** Generic object payload used by operational entries. */
|
||||
export const GenericObjectPayload = type("Record<string, unknown>")
|
||||
|
||||
/** Fallback payload shape for tool calls, tool results, and system notes. */
|
||||
export type GenericObjectPayload = typeof GenericObjectPayload.infer
|
||||
|
||||
/** Union of payload shapes that can be stored on a conversation entry. */
|
||||
export type ConversationEntryPayload =
|
||||
| UserMessagePayload
|
||||
| AssistantMessagePayload
|
||||
| AttachmentPayload
|
||||
| ContextSummaryPayload
|
||||
| GenericObjectPayload
|
||||
@@ -6,6 +6,25 @@ export { Context, contextKey, serializeKey } from "./context"
|
||||
export type { ActionDefinition } from "./action"
|
||||
export { UnknownActionError } from "./action"
|
||||
|
||||
// Conversation
|
||||
export type { ConversationEntryPayload } from "./conversation"
|
||||
export {
|
||||
AssistantMessagePayload,
|
||||
AttachmentPayload,
|
||||
AttachmentType,
|
||||
ContextSummary,
|
||||
ContextSummaryPayload,
|
||||
ConversationEntryKind,
|
||||
ConversationEntryMetadata,
|
||||
ConversationEntryVisibility,
|
||||
GenericObjectPayload,
|
||||
JsonMessagePart,
|
||||
MessagePart,
|
||||
ModelRunMetadata,
|
||||
TextMessagePart,
|
||||
UserMessagePayload,
|
||||
} from "./conversation"
|
||||
|
||||
// Feed
|
||||
export type { FeedItem, FeedItemRenderer, FeedItemSignals, RenderedFeedItem, Slot } from "./feed"
|
||||
export { TimeRelevance } from "./feed"
|
||||
|
||||
Reference in New Issue
Block a user