Files
goodbuddy/src/shared/assistant-contracts.ts
T
mesalogo d80d42b032 feat(storage): move desktop SQLite ownership into a storage utility process
Main no longer opens desktop business SQLite. A single utility-process
storage owner holds the Assistant, Knowledge, attachment, document-result,
remote-binding and native model-ledger databases and runs whole domain
transactions; Main and Runtime callers use explicit async domain operations.

- Remove the Main readWithFallback path and the supervision worker's
  separate business write connection; manifests are scanned read-only and
  committed by the sole writer.
- Remote events are ACKed only after commit; process loss reports
  unconfirmed writes instead of replaying them.
- Use native utilityProcess messaging with one in-flight bound (32) and
  ordered waiting instead of capacity rejection and retries.
- Start the storage process before the window so startup overlaps.
- Keep short tool output in memory and store full output once in owned
  storage; paging, branching, restart and deletion reuse that copy.
- Put required runtime launch files under goodbuddy-prefixed owned temp
  directories and reclaim confirmed stale ones; stop writing duplicate
  parsed.md.
- Add lint guards for SQLite ownership and architecture, concurrency
  acceptance fixtures, and maintenance/architecture documentation.

Validation: typecheck and lint pass. Alternating baseline (edf019aa) vs
candidate runs show no regression on single-session, C01, C02, C04 and a
5-minute C06; Main p99 drops from ~21 ms to ~16.7 ms under concurrency.
Fresh portable copy (1.75M events) upgrade content unchanged; real-model
output paging passes with 4 external requests. Full npm test runs had
1-9 different timing-sensitive failures per run that pass when rerun.
Not covered: C03 review publication, C07 source body, app-level C05.
2026-10-06 15:40:18 +08:00

1293 lines
37 KiB
TypeScript

import { z } from 'zod'
import { runtimeChecklistSchema } from './runtime-checklist'
import { externalKnowledgeLocatorSchema } from './external-knowledge-contracts'
import {
runtimeSelectionLayerSchema,
type AgentRuntimeSelection
} from './runtime-selection-contracts'
import { sshHostIdSchema } from './ssh-host-contracts'
export const assistantIdSchema = z.string().uuid()
export const executionStatsInputSchema = z.union([
z.object({ conversationId: assistantIdSchema }).strict(),
z.object({ projectId: assistantIdSchema }).strict()
])
export type ExecutionStatsInput = z.infer<typeof executionStatsInputSchema>
export interface ExecutionStats {
/** Accumulated running time, including open segments through asOf. */
durationMs: number
/** Number of open segments; the UI adds (now - asOf) * runningCount. */
runningCount: number
/** Pre-timing history or an interrupted segment has unknown duration. */
incomplete: boolean
/** Main epoch milliseconds at snapshot time. */
asOf: number
/** Project card totals; empty for conversation queries. */
taskDurations: Array<{ id: string; durationMs: number; runningCount: number; incomplete: boolean }>
}
export const projectKindSchema = z.enum(['user', 'channel'])
export const projectChannels = [
'weixin',
'wecom',
'dingtalk',
'telegram'
] as const
export const projectChannelSchema = z.enum(projectChannels)
export const projectChannelLabels: Record<ProjectChannel, string> = {
weixin: '微信 ClawBot',
wecom: '企业微信',
dingtalk: '钉钉',
telegram: 'Telegram'
}
export type ProjectKind = z.infer<typeof projectKindSchema>
export type ProjectChannel = z.infer<typeof projectChannelSchema>
const projectExecutionSpacePathSchema = z.string().max(4_096)
export const activityRecordSchema = z
.object({
id: z.string().min(1).max(256),
conversationId: z.string().min(1).max(256),
requestId: z.string().min(1).max(256),
callId: z.string().min(1).max(256).optional(),
scope: z.discriminatedUnion('kind', [
z.object({ kind: z.literal('global') }).strict(),
z
.object({
kind: z.literal('project'),
projectId: z.string().min(1).max(256),
projectName: z.string().min(1).max(120)
})
.strict(),
z.object({ kind: z.literal('unavailable') }).strict()
]),
kind: z.enum([
'request',
'tool',
'approval',
'subagent',
'result'
]),
title: z.string().min(1).max(240),
detail: z.string(),
status: z.enum([
'pending',
'running',
'completed',
'failed',
'denied',
'cancelled',
'interrupted'
]),
createdAt: z.number().finite().nonnegative()
})
.strict()
export const activityHistorySnapshotSchema = z
.object({
records: z.array(activityRecordSchema),
legacyHistoryMayBeIncomplete: z.boolean()
})
.strict()
/**
* Incremental activity history change (PERF-15). Changes apply in order to the
* stored, newest-first list; the result is identical to `replace` with the
* same final list. A record is identified by its `id` (first occurrence when
* legacy history contains duplicate IDs).
* - `upsert` + `position: 'front'` inserts the record, or moves the existing
* record to the front with the new content (renderer `upsertActivityRecord`).
* - `upsert` + `position: 'in-place'` replaces the existing record where it
* is; a record that does not exist yet is inserted at the front.
* - `remove` deletes every record with the ID.
* The other changes match shown records (first occurrences) by content, so
* the renderer does not need to have them loaded:
* - `upsert-call`: a tool/subagent record with a callId. The first record
* of the same kind, request and call is moved to the front with the new
* content but its own id, createdAt and scope (nothing happens when it is
* unchanged and already first); without one the record is inserted at the
* front (renderer `upsertActivityRecord`).
* - `update-request`: every request record of the request gets the status
* (and the detail, when given), in place.
* - `resolve-approval`: the first pending approval of the conversation gets
* the status and `detailLine` appended on a new line, in place.
* - `settle-request`: every pending/running non-request record of the
* request gets the status and `detailLine` appended, in place.
* - `remove-request`: deletes every record of the request.
* - `remove-call`: deletes the tool records of the request with the callId.
*/
const activityIdSchema = z.string().min(1).max(256)
const activityStatusSchema = activityRecordSchema.shape.status
const activityDetailLineSchema = z.string().max(1_000_000)
export const activityHistoryChangeSchema = z.discriminatedUnion('type', [
z.object({
type: z.literal('upsert'),
record: activityRecordSchema,
position: z.enum(['front', 'in-place'])
}).strict(),
z.object({
type: z.literal('remove'),
id: activityIdSchema
}).strict(),
z.object({ type: z.literal('remove-duplicates') }).strict(),
z.object({
type: z.literal('upsert-call'),
record: activityRecordSchema.refine(
(record) => (record.kind === 'tool' || record.kind === 'subagent') && record.callId !== undefined,
'upsert-call needs a tool or subagent record with a callId'
)
}).strict(),
z.object({
type: z.literal('update-request'),
requestId: activityIdSchema,
status: activityStatusSchema,
detail: z.string().optional()
}).strict(),
z.object({
type: z.literal('resolve-approval'),
conversationId: activityIdSchema,
status: activityStatusSchema,
detailLine: activityDetailLineSchema
}).strict(),
z.object({
type: z.literal('settle-request'),
requestId: activityIdSchema,
status: activityStatusSchema,
detailLine: activityDetailLineSchema
}).strict(),
z.object({
type: z.literal('remove-request'),
requestId: activityIdSchema
}).strict(),
z.object({
type: z.literal('remove-call'),
requestId: activityIdSchema,
callId: activityIdSchema
}).strict()
])
export const activityHistoryUpdateSchema = z
.object({
changes: z.array(activityHistoryChangeSchema).max(20_000),
legacyHistoryMayBeIncomplete: z.boolean().optional()
})
.strict()
/**
* Activity history pages (PERF-15), newest first. A record ID shows once:
* legacy duplicates after the first occurrence are left out. `before` is the
* `nextBefore` of the previous page.
*/
export const activityHistoryFilterSchema = z.enum(['all', 'active', 'failed'])
export const activityHistoryPageRequestSchema = z
.object({
before: z.number().int().optional(),
limit: z.number().int().min(1).max(500),
filter: activityHistoryFilterSchema.optional(),
projectId: z.string().min(1).max(256).optional(),
conversationId: z.string().min(1).max(256).optional()
})
.strict()
export const activityHistorySummaryRequestSchema = z
.object({
/** Conversations whose title and status are needed (the loaded ones). */
conversationIds: z.array(z.string().min(1).max(256)).max(5_000)
})
.strict()
export const activityHistoryReconcileRequestSchema = z
.object({
activeRequestIds: z.array(z.string().min(1).max(256)).max(10_000),
/** The localized line appended to records marked interrupted. */
interruptedDetail: z.string().max(1_000)
})
.strict()
export type ActivityHistoryFilter = z.infer<typeof activityHistoryFilterSchema>
export type ActivityHistoryPageRequest = z.infer<typeof activityHistoryPageRequestSchema>
export type ActivityHistoryPage = {
records: ActivityRecord[]
/** Set when older records may follow. */
nextBefore?: number
}
export type ActivityHistorySummaryRequest = z.infer<typeof activityHistorySummaryRequestSchema>
export type ActivityHistorySummary = {
/** Record counts per filter, over the whole history. */
counts: Record<ActivityHistoryFilter, number>
/**
* Per requested conversation: the title of its newest request record and
* the conversation status (latest request's result, else latest result,
* else latest record), over the whole history.
*/
conversations: Record<string, { title?: string; status: ActivityRecord['status'] }>
legacyHistoryMayBeIncomplete: boolean
}
export type ActivityHistoryReconcileRequest = z.infer<typeof activityHistoryReconcileRequestSchema>
export type ActivityRecord = z.infer<typeof activityRecordSchema>
export type ActivityHistorySnapshot = z.infer<
typeof activityHistorySnapshotSchema
>
export type ActivityHistoryChange = z.infer<typeof activityHistoryChangeSchema>
export type ActivityHistoryUpdate = z.infer<typeof activityHistoryUpdateSchema>
/** The stored record of each upsert-call change, in order. */
export type ActivityHistoryUpdateResult = { calls: ActivityRecord[] }
export const projectExecutionSpaceSchema = z.discriminatedUnion(
'kind',
[
z
.object({
kind: z.literal('local'),
rootPath: projectExecutionSpacePathSchema
})
.strict(),
z
.object({
kind: z.literal('ssh'),
hostId: sshHostIdSchema,
remoteRootPath: projectExecutionSpacePathSchema.refine(
(value) => value.trim().length > 0,
'远程项目目录不能为空'
)
})
.strict()
]
)
export const persistedProjectExecutionSpaceSchema =
projectExecutionSpaceSchema
export type ProjectExecutionSpace = z.output<
typeof persistedProjectExecutionSpaceSchema
>
export const projectUpdateSchema = z
.object({
name: z.string().trim().min(1).max(120),
description: z.string().trim().max(2_000),
rootPath: z.string().trim().max(4_096),
/** Project layer; absent means follow the global settings. */
runtimeSelection: runtimeSelectionLayerSchema.optional()
})
.strict()
export const projectCreateSchema = projectUpdateSchema.extend({
rootPath: z.string().trim().min(1).max(4_096)
})
export type ProjectCreateInput = z.infer<typeof projectCreateSchema>
export const conversationAttachmentSchema = z
.object({
id: assistantIdSchema,
name: z.string().trim().min(1).max(500),
size: z.number().int().nonnegative().max(12 * 1024 * 1024),
preview: z.string().max(500),
kind: z.enum(['text', 'image']),
resourceId: z.string().uuid().optional(),
attachmentId: z.string().uuid().optional(),
resultId: z.string().uuid().optional(),
completeness: z.enum(['complete', 'partial', 'images-only']).optional(),
parsingState: z.enum(['parsing', 'failed', 'interrupted']).optional(),
parsingError: z.string().max(1000).optional(),
originalName: z.string().max(500).optional(),
originalSize: z.number().int().nonnegative().max(20 * 1024 * 1024).optional(),
originalMime: z.enum(['image/png', 'image/jpeg', 'image/webp']).optional(),
imageWidth: z.number().int().positive().optional(),
imageHeight: z.number().int().positive().optional(),
sendMode: z.enum(['image', 'text']).optional(),
provenance: z.object({ resultId: z.string().uuid(), imageId: z.string().uuid(), documentName: z.string().max(500), pageNumber: z.number().int() }).optional(),
thumbnailUrl: z
.string()
.max(2_000_000)
.refine(
(value) =>
value.startsWith('data:image/png;base64,') ||
value.startsWith('data:image/jpeg;base64,'),
'会话附件缩略图格式无效'
)
.optional(),
contentUrl: z
.string()
.max(400_000)
.refine(
(value) =>
value.startsWith('data:image/png;base64,') ||
value.startsWith('data:image/jpeg;base64,'),
'会话附件图片格式无效'
)
.optional()
})
.strict()
export type ConversationAttachment = z.infer<
typeof conversationAttachmentSchema
>
export const conversationQueueItemSchema = z
.object({
hasAttachments: z.boolean().optional(),
error: z.string().max(1000).optional(),
id: assistantIdSchema,
conversationId: assistantIdSchema,
source: z.enum(['user', 'schedule']),
label: z.string().trim().min(1).max(200),
createdAt: z.string().datetime({ offset: true }),
scheduleRunId: assistantIdSchema.optional(),
scheduleId: assistantIdSchema.optional(),
taskId: assistantIdSchema.optional()
})
.strict()
export type ConversationQueueItem = z.infer<
typeof conversationQueueItemSchema
>
export const toolOutputReferenceSchema = z.object({
handle: z.string().min(1),
nextCursor: z.number().int().nonnegative(),
totalBytes: z.number().int().nonnegative()
}).strict()
export type ToolOutputReference = z.infer<typeof toolOutputReferenceSchema>
export const conversationToolActivitySchema = z
.object({
callId: z.string().optional(),
name: z.string(),
state: z.enum([
'pending',
'running',
'completed',
'failed',
'recoverable',
'cancelled',
'interrupted'
]),
summary: z.string(),
input: z.string().optional(),
output: z.string().optional(),
outputReferences: z.array(toolOutputReferenceSchema).optional(),
error: z.string().optional()
})
.strict()
export const conversationMessageBlockSchema = z.discriminatedUnion('type', [
z
.object({
id: assistantIdSchema,
type: z.literal('text'),
content: z.string().min(1)
})
.strict(),
z
.object({
id: assistantIdSchema,
type: z.literal('reasoning'),
content: z.string().min(1)
})
.strict(),
z
.object({
id: assistantIdSchema,
type: z.literal('tool'),
tool: conversationToolActivitySchema
})
.strict(),
z
.object({
id: assistantIdSchema,
type: z.literal('subagent'),
childTaskId: assistantIdSchema
})
.strict(),
z
.object({
id: assistantIdSchema,
type: z.literal('question'),
questionId: z.string().min(1)
})
.strict()
])
export const conversationMessageBlocksSchema = z.array(
conversationMessageBlockSchema
)
export type ConversationToolActivity = z.infer<
typeof conversationToolActivitySchema
>
export type ConversationMessageBlock = z.infer<
typeof conversationMessageBlockSchema
>
export const subagentActorSchema = z
.object({
kind: z.literal('direct-model'),
label: z.literal('编程 Subagent')
})
.strict()
export type SubagentActor = z.infer<typeof subagentActorSchema>
const conversationSubagentActivityBaseSchema = z.object({
childTaskId: assistantIdSchema,
routingMode: z.enum(['manual', 'smart', 'native']),
runtimeCallId: z.string().trim().min(1).max(256).optional(),
state: z.enum([
'queued',
'running',
'completed',
'failed',
'cancelled'
]),
reason: z.string().trim().min(1).max(240).optional(),
progress: conversationMessageBlocksSchema.optional(),
output: z.string().optional(),
outputReference: toolOutputReferenceSchema.optional(),
error: z.string().trim().min(1).max(1_000).optional()
})
export const legacyConversationSubagentActivitySchema =
conversationSubagentActivityBaseSchema
.extend({
expertId: assistantIdSchema,
expertName: z.string().trim().min(1).max(80)
})
.strict()
export const actorConversationSubagentActivitySchema =
conversationSubagentActivityBaseSchema
.extend({
actor: subagentActorSchema
})
.strict()
export const conversationSubagentActivitySchema = z.union([
legacyConversationSubagentActivitySchema,
actorConversationSubagentActivitySchema
])
export type ConversationSubagentActivity = z.infer<
typeof conversationSubagentActivitySchema
>
export const conversationContextCompressionMarkerSchema = z
.object({
state: z.enum(['compressing', 'completed', 'failed']),
scope: z.enum(['conversation', 'agent-run']).optional(),
estimatedBeforeTokens: z.number().int().nonnegative(),
estimatedAfterTokens: z.number().int().nonnegative().optional(),
compressionCount: z.number().int().positive().optional()
})
.strict()
export type ConversationContextCompressionMarker = z.infer<
typeof conversationContextCompressionMarkerSchema
>
export const conversationAnsweredQuestionSchema = z
.object({
questionId: z.string().min(1),
skipped: z.boolean().optional(),
questions: z
.array(
z
.object({
header: z.string(),
question: z.string(),
options: z
.array(
z
.object({
label: z.string(),
description: z.string()
})
.strict()
),
multiple: z.boolean(),
custom: z.boolean(),
answer: z.array(z.string()).optional()
})
.strict()
)
})
.strict()
export type ConversationAnsweredQuestion = z.infer<
typeof conversationAnsweredQuestionSchema
>
export const conversationMessageSchema = z
.object({
id: assistantIdSchema,
queueItemId: assistantIdSchema.optional(),
role: z.enum(['user', 'assistant']),
content: z.string(),
reasoning: z.string().optional(),
runtimeChecklist: runtimeChecklistSchema.optional(),
blocks: conversationMessageBlocksSchema.optional(),
displayCaptureTruncated: z.boolean().optional(),
createdAt: z.number().int().nonnegative(),
state: z.enum(['streaming', 'complete', 'error']),
terminalStatus: z.enum(['failed', 'cancelled']).optional(),
status: z.string().max(4_000).optional(),
contextCompression:
conversationContextCompressionMarkerSchema.optional(),
contextCompressions: z
.array(conversationContextCompressionMarkerSchema)
.max(2)
.optional(),
tools: z.array(conversationToolActivitySchema).optional(),
subagents: z.array(conversationSubagentActivitySchema).optional(),
sources: z.array(z.string().max(8_192)).max(100).optional(),
sourceReferences: z
.array(
z
.object({
libraryId: assistantIdSchema,
libraryName: z.string().max(200),
documentId: assistantIdSchema.optional(),
external: externalKnowledgeLocatorSchema.optional(),
warnings: z.array(z.string().max(500)).max(20).optional(),
chunkId: assistantIdSchema.optional(),
documentName: z.string().max(500),
sourceName: z.string().max(500),
sourceLocation: z.string().max(4_096).optional(),
locator: z.string().max(1_000).optional(),
snippet: z.string().max(16_000),
rank: z.number().finite(),
score: z.number().finite().optional(),
lexicalRank: z.number().int().positive().optional(),
vectorRank: z.number().int().positive().optional(),
graphRank: z.number().int().positive().optional(),
similarity: z.number().min(-1).max(1).optional(),
retrievalChannels: z
.array(z.enum(['fts', 'cjk', 'vector', 'graph']))
.max(4)
.optional(),
evidenceIds: z
.array(assistantIdSchema)
.max(100)
.optional()
})
.strict()
)
.max(20)
.optional(),
knowledgeRetrieval: z
.object({
mode: z.literal('always'),
state: z.enum([
'searching',
'succeeded',
'zero',
'degraded',
'failed',
'cancelled'
]),
libraryCount: z.number().int().min(1).max(20),
resultCount: z.number().int().nonnegative().max(20),
durationMs: z.number().int().nonnegative().optional(),
usedChannels: z
.array(z.enum(['fts', 'cjk', 'vector', 'graph']))
.max(4),
warnings: z.array(z.string().max(500)).max(20)
})
.strict()
.optional(),
artifactIds: z.array(assistantIdSchema).max(8).optional(),
imageOperations: z.array(imageOperationSchema).optional(),
imageSourceArtifactIds: z.array(assistantIdSchema).max(8).optional(),
imageContextNotice: z
.enum(['reference-unavailable', 'editing-unavailable'])
.optional(),
task: z
.object({
id: assistantIdSchema,
title: z.string().trim().min(1).max(120)
})
.strict()
.optional(),
attachments: z
.array(conversationAttachmentSchema)
.max(8)
.optional(),
answeredQuestions: z.array(conversationAnsweredQuestionSchema).optional()
})
.strict()
export type ConversationMessage = z.infer<
typeof conversationMessageSchema
>
export const conversationHistoryMessageSchema =
conversationMessageSchema
.pick({
role: true,
content: true
})
.strict()
export const conversationContextMetricsSchema = z
.object({
runtimeSelectionKey: z.string().trim().min(1).max(1_000),
contextTokens: z.number().int().nonnegative().max(50_000_000),
source: z.enum(['provider', 'estimated']),
basis: z.enum(['model-call', 'conversation']).optional(),
// Accepted only to migrate snapshots saved before display settings
// were derived from the current Runtime configuration.
effectiveTriggerTokens: z
.number()
.int()
.nonnegative()
.max(10_000_000)
.optional(),
contextWindowTokens: z
.number()
.int()
.nonnegative()
.max(10_000_000)
.optional(),
compressionEnabled: z.boolean().optional(),
})
.strict()
.transform((metrics) => ({
runtimeSelectionKey: metrics.runtimeSelectionKey,
contextTokens: metrics.contextTokens,
source: metrics.source,
basis: metrics.basis
}))
export type ConversationContextMetrics = z.infer<
typeof conversationContextMetricsSchema
>
export const conversationContextCompressionStateSchema = z
.object({
coveredHistoryDigest: z.string().regex(/^[0-9a-f]{64}$/u),
coveredMessageCount: z.number().int().nonnegative(),
coveredFromMessageId: assistantIdSchema.optional(),
coveredThroughMessageId: assistantIdSchema.optional(),
summary: z.string().trim().min(1).max(100_000)
})
.strict()
export type ConversationContextCompressionState = z.infer<
typeof conversationContextCompressionStateSchema
>
export const conversationBranchInputSchema = z
.object({
sourceConversationId: assistantIdSchema,
title: z.string().trim().min(1).max(200)
})
.strict()
export type ConversationBranchInput = z.infer<
typeof conversationBranchInputSchema
>
export const conversationBranchSchema = z
.object({
sourceConversationId: assistantIdSchema,
sourceTitle: z.string().trim().min(1).max(200)
})
.strict()
export const liveAgentQuestionSchema = z.object({
requestId: z.string().min(1),
type: z.literal('question'),
questionId: z.string().min(1),
childTaskId: z.string().optional(),
questions: z.array(z.object({
header: z.string(),
question: z.string(),
options: z.array(z.object({ label: z.string(), description: z.string() })),
multiple: z.boolean(),
custom: z.boolean()
}))
})
export type LiveAgentQuestion = z.infer<typeof liveAgentQuestionSchema>
export const conversationSnapshotSchema = z
.object({
id: assistantIdSchema,
projectId: assistantIdSchema.optional(),
/** Conversation layer; absent means follow the project. */
runtimeSelection: runtimeSelectionLayerSchema.optional(),
knowledgeLibraryIds: z.array(assistantIdSchema).max(20).optional(),
knowledgeRetrievalMode: z.enum(['auto', 'always']).optional(),
storyGraphEnabled: z.boolean().optional(),
contextMetrics: conversationContextMetricsSchema.optional(),
contextCompressionState:
conversationContextCompressionStateSchema.optional(),
remote: z
.object({
channel: projectChannelSchema,
accountDisplay: z.string().trim().min(1).max(200),
conversationType: z.enum(['direct', 'group'])
})
.strict()
.optional(),
branch: conversationBranchSchema.optional(),
title: z.string().trim().min(1).max(200),
// Absent in older snapshots means unpinned; written only via setPinned.
pinned: z.boolean().optional(),
updatedAt: z.number().int().nonnegative(),
// Main-only live projection. Never part of a saved conversation header.
activeRequest: z.object({
requestId: assistantIdSchema,
messageId: assistantIdSchema,
questions: z.array(liveAgentQuestionSchema)
}).optional(),
messages: z.array(conversationMessageSchema)
})
.strict()
export type ConversationSnapshot = z.infer<
typeof conversationSnapshotSchema
>
// Read-only list projection. An absent summary means messages are complete.
// Never serialize messageSummary as a persisted conversation/header.
export type ConversationListSnapshot = ConversationSnapshot & {
messageSummary?: {
count: number
firstRole?: ConversationMessage['role']
latestMessageAt?: number
}
}
export const conversationListRequestSchema = z.object({
detailIds: z.array(assistantIdSchema).default([])
}).strict()
export const conversationSearchRequestSchema = z.object({
query: z.string(),
conversationIds: z.array(assistantIdSchema).default([])
}).strict()
export const conversationSetPinnedSchema = z.object({
conversationId: assistantIdSchema,
pinned: z.boolean()
}).strict()
export const conversationSetStoryGraphSchema = z.object({
conversationId: assistantIdSchema,
enabled: z.boolean()
}).strict()
export type ConversationSetPinnedInput = z.infer<
typeof conversationSetPinnedSchema
>
export const conversationSnapshotsSchema = z
.array(conversationSnapshotSchema)
.max(100)
export const localConversationHeaderSchema = conversationSnapshotSchema
.omit({
messages: true,
pinned: true,
remote: true,
activeRequest: true
})
export type LocalConversationHeader = z.infer<
typeof localConversationHeaderSchema
>
export const localConversationSaveSchema = z
.object({
header: localConversationHeaderSchema,
messages: z.array(conversationMessageSchema)
})
.strict()
export type LocalConversationSaveInput = z.infer<
typeof localConversationSaveSchema
>
export const localConversationSaveBatchSchema = z
.array(localConversationSaveSchema)
.max(100)
export type LocalConversationSaveBatch = z.infer<
typeof localConversationSaveBatchSchema
>
export type AssistantProject = ProjectCreateInput & {
id: string
kind: ProjectKind
executionSpace: ProjectExecutionSpace
channel?: ProjectChannel
readonly builtInDefault?: boolean
status: 'active' | 'archived'
createdAt: string
updatedAt: string
}
export const builtInDefaultProjectSeedName = '默认项目'
export const builtInDefaultProjectSeedDescription =
'GoodBuddy 默认工作区'
export function isUntouchedBuiltInDefaultProject(
project: AssistantProject
): boolean {
return (
project.builtInDefault === true &&
project.name === builtInDefaultProjectSeedName &&
project.description === builtInDefaultProjectSeedDescription
)
}
export type WorkspaceChanges = {
rootPath: string
available: boolean
status: string
patch: string
stagedPatch?: string
files: WorkspaceChangedFile[]
truncated: boolean
error?: string
}
export type WorkspaceChangedFile = {
path: string
status: string
previousPath?: string
}
export type WorkspaceDirectoryEntry = {
name: string
path: string
type: 'file' | 'directory'
modifiedAt?: string
createdAt?: string
}
export type WorkspaceDirectoryListing = {
path: string
entries: WorkspaceDirectoryEntry[]
truncated: boolean
}
export type WorkspaceFilePreview = {
path: string
name: string
content: string
mimeType: 'text/markdown' | 'text/plain' | 'application/json'
size: number
offsetBytes: number
nextOffsetBytes: number
truncated: boolean
}
export type AssistantTaskStatus =
| 'queued'
| 'running'
| 'waiting_approval'
| 'paused'
| 'idle'
| 'completed'
| 'failed'
| 'cancelled'
| 'interrupted'
export type AssistantTask = {
id: string
projectId?: string
conversationId?: string
scheduleId?: string
parentTaskId?: string
expertId?: string
routingMode?: 'manual' | 'smart'
title: string
instructions: string
origin: 'user' | 'assistant' | 'schedule' | 'delegation' | 'subagent'
status: AssistantTaskStatus
progress?: number
createdAt: string
startedAt?: string
completedAt?: string
error?: string
}
export type ModelUsageCallInput = {
requestId: string
callId: string
runtime: string
provider: string
model: string
input: number
output: number
cacheRead: number
cacheWrite: number
}
export const tokenUsageSystemSources = [
'heartbeat',
'supervision',
'magic-notes',
'knowledge',
'delegation'
] as const
export type TokenUsageSystemSource = (typeof tokenUsageSystemSources)[number]
export type SystemModelUsageInput = Omit<
ModelUsageCallInput,
'requestId' | 'callId'
> & {
source: Extract<TokenUsageSystemSource, 'knowledge'>
bucket: 'graph-extraction' | 'embedding'
}
export type TokenUsageTotals = {
callCount: number
input: number
output: number
cacheRead: number
cacheWrite: number
cacheInput?: number
totalTokens: number
}
export type TokenUsageRecord = {
requestId: string
/** Set when the usage came from background system work instead of a conversation. */
systemSource?: TokenUsageSystemSource
projectId?: string
projectName?: string
conversationId?: string
conversationTitle?: string
runtime: string
provider: string
model: string
callCount: number
input: number
output: number
cacheRead: number
cacheWrite: number
cacheInput?: number
totalTokens: number
}
export type TokenUsageSummary = {
/** Totals across all recorded usage. */
totals: TokenUsageTotals
/** Totals for conversation work only; absent in legacy summaries. */
conversationTotals?: TokenUsageTotals
/** Totals for background system work only; absent in legacy summaries. */
systemTotals?: TokenUsageTotals
records: TokenUsageRecord[]
}
export type AssistantArtifact = {
id: string
projectId?: string
taskId?: string
kind: 'markdown' | 'text' | 'json' | 'image' | 'file'
title: string
mimeType: string
content?: string
byteSize: number
createdAt: string
updatedAt: string
}
export const memoryCreateSchema = z
.object({
scope: z.enum(['global', 'project', 'conversation']),
scopeId: z.string().max(256).optional(),
type: z.enum(['preference', 'fact', 'summary', 'procedure']),
content: z.string().trim().min(1).max(8_000)
})
.strict()
export type MemoryCreateInput = z.infer<typeof memoryCreateSchema>
export type AssistantMemory = MemoryCreateInput & {
id: string
confidence: number
salience: number
status: 'proposed' | 'confirmed' | 'rejected'
createdAt: string
updatedAt: string
}
export const scheduleCreateSchema = z
.object({
projectId: z.string().uuid().optional(),
conversationId: z.string().uuid().optional(),
title: z.string().trim().min(1).max(120),
prompt: z.string().trim().min(1).max(100_000),
recurrence: z.enum(['once', 'daily', 'weekly']),
nextRunAt: z.string().datetime({ offset: true }),
runImmediately: z.boolean().optional()
})
.strict()
.refine((input) => !input.runImmediately || input.recurrence === 'once', {
message: '立即执行只支持单次任务',
path: ['recurrence']
})
export type ScheduleCreateInput = z.input<typeof scheduleCreateSchema>
export type AssistantSchedule = Omit<z.output<typeof scheduleCreateSchema>, 'runImmediately'> & {
id: string
taskId: string
conversationId: string
/** A concrete selection captured by legacy schedule templates. */
runtimeSelection?: AgentRuntimeSelection
enabled: boolean
lastRunAt?: string
createdAt: string
updatedAt: string
}
export const heartbeatRecurrenceSchema = z.discriminatedUnion('type', [
z
.object({
type: z.literal('daily'),
localTime: z
.string()
.regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/)
})
.strict(),
z
.object({
type: z.literal('weekly'),
localTime: z
.string()
.regex(/^(?:[01]\d|2[0-3]):[0-5]\d$/),
weekday: z.number().int().min(0).max(6)
})
.strict()
])
export const heartbeatScopeSchema = z.discriminatedUnion('kind', [
z
.object({
kind: z.literal('global')
})
.strict(),
z
.object({
kind: z.literal('projects'),
projectIds: z
.array(assistantIdSchema)
.min(1)
.max(100)
.refine(
(projectIds) => new Set(projectIds).size === projectIds.length,
'Project IDs must be unique'
)
})
.strict()
])
// How the supervisor intervenes after an automatic review. Real-time
// conversation intervention is not offered until it is implemented.
export const heartbeatInterventionSchema = z.enum(['suggest', 'memory'])
export type HeartbeatIntervention = z.infer<typeof heartbeatInterventionSchema>
export const defaultHeartbeatIntervention: HeartbeatIntervention = 'suggest'
export const heartbeatCreateSchema = z
.object({
scope: heartbeatScopeSchema,
name: z.string().trim().min(1).max(120),
timezone: z.string().trim().min(1).max(100),
recurrence: heartbeatRecurrenceSchema,
enabled: z.boolean(),
lookbackHours: z.number().int().min(1).max(24 * 30),
retentionDays: z.number().int().min(1).max(365),
intervention: heartbeatInterventionSchema.optional()
})
.strict()
export const heartbeatUpdateSchema = heartbeatCreateSchema
export const heartbeatListSchema = z
.object({
projectId: assistantIdSchema.optional()
})
.strict()
export const heartbeatHistorySchema = z
.object({
configId: assistantIdSchema.optional(),
limit: z.number().int().min(1).max(200).default(50)
})
.strict()
export const heartbeatIdSchema = z
.object({
id: assistantIdSchema
})
.strict()
export const heartbeatUpdateRequestSchema = z
.object({
id: assistantIdSchema,
config: heartbeatUpdateSchema
})
.strict()
export const heartbeatPauseSchema = z
.object({
id: assistantIdSchema,
paused: z.boolean()
})
.strict()
export const heartbeatRunNowSchema = z
.object({
id: assistantIdSchema,
idempotencyKey: z.string().trim().min(1).max(200)
})
.strict()
export const heartbeatSummaryOutputSchema = z
.object({
summary: z.string().trim().min(1).max(12_000),
highlights: z.array(z.string().trim().min(1).max(1_000)).max(20),
proposedMemories: z
.array(
z.discriminatedUnion('scope', [
z
.object({
scope: z.literal('global'),
type: z.enum([
'preference',
'fact',
'summary',
'procedure'
]),
content: z.string().trim().min(1).max(8_000),
confidence: z.number().min(0).max(1),
salience: z.number().min(0).max(1)
})
.strict(),
z
.object({
scope: z.literal('project'),
projectId: assistantIdSchema,
type: z.enum([
'preference',
'fact',
'summary',
'procedure'
]),
content: z.string().trim().min(1).max(8_000),
confidence: z.number().min(0).max(1),
salience: z.number().min(0).max(1)
})
.strict()
])
)
.max(10),
followUpTasks: z
.array(
z
.object({
title: z.string().trim().min(1).max(200),
instructions: z.string().trim().min(1).max(8_000),
projectId: assistantIdSchema.optional()
})
.strict()
)
.max(10)
})
.strict()
export type HeartbeatRecurrence = z.infer<
typeof heartbeatRecurrenceSchema
>
export type HeartbeatScope = z.infer<typeof heartbeatScopeSchema>
export type HeartbeatCreateInput = z.infer<
typeof heartbeatCreateSchema
>
export type HeartbeatUpdateInput = z.infer<
typeof heartbeatUpdateSchema
>
export type HeartbeatSummaryOutput = z.infer<
typeof heartbeatSummaryOutputSchema
>
export type HeartbeatRunStatus =
| 'claimed'
| 'no_change'
| 'completed'
| 'failed'
| 'skipped'
export type AssistantHeartbeatConfig = HeartbeatCreateInput & {
id: string
nextRunAt: string
lastRunAt?: string
lastStatus?: HeartbeatRunStatus
createdAt: string
updatedAt: string
}
export type AssistantHeartbeatRun = {
id: string
configId: string
trigger: 'scheduled' | 'manual'
scheduledFor: string
status: HeartbeatRunStatus
attemptCount: number
nextAttemptAt?: string
startedAt?: string
completedAt?: string
error?: string
entryId?: string
createdAt: string
updatedAt: string
}
export type AssistantHeartbeatEntry = {
id: string
configId: string
runId: string
scheduledFor: string
summary: string
highlights: string[]
artifactId?: string
proposedMemoryIds: string[]
followUpTaskIds: string[]
createdAt: string
}
const routingKeywordSchema = z
.string()
.transform((value) =>
value.normalize('NFKC').trim().replace(/\s+/gu, ' ').toLowerCase()
)
.pipe(z.string().min(2).max(48))
export const expertCreateSchema = z
.object({
name: z.string().trim().min(1).max(80),
description: z.string().trim().max(500),
systemInstructions: z.string().trim().min(1).max(20_000),
modelProfileId: assistantIdSchema.optional(),
routingKeywords: z
.array(routingKeywordSchema)
.max(32)
.default([])
.transform((keywords) => [...new Set(keywords)])
})
.strict()
export type ExpertCreateInput = z.input<typeof expertCreateSchema>
export type ExpertUpdateInput = ExpertCreateInput
export type AssistantExpert = z.output<typeof expertCreateSchema> & {
id: string
enabled: boolean
createdAt: string
updatedAt: string
}
import { imageOperationSchema } from './image-generation-contracts'