mirror of
https://github.com/supermemoryai/supermemory.git
synced 2026-08-28 05:25:33 +00:00
360 lines
9.5 KiB
TypeScript
360 lines
9.5 KiB
TypeScript
export type NovaResearchStatus =
|
|
| "queued"
|
|
| "running"
|
|
| "awaiting_input"
|
|
| "completed"
|
|
| "failed"
|
|
| "cancelled"
|
|
|
|
export type NovaResearchFailure = {
|
|
code: string
|
|
stage: string
|
|
retryable: boolean
|
|
incidentId?: string | null
|
|
/** A user-safe explanation supplied by the API. Legacy `run.error` is never rendered. */
|
|
message?: string | null
|
|
}
|
|
|
|
export type NovaResearchConnectionState = {
|
|
status: "connected" | "reconnecting"
|
|
attempts: number
|
|
}
|
|
|
|
export type NovaResearchPlan = {
|
|
goal: string
|
|
steps: Array<{
|
|
id: string
|
|
title: string
|
|
status: "pending" | "in_progress" | "complete" | "skipped"
|
|
}>
|
|
}
|
|
|
|
export type NovaResearchSource = {
|
|
id: string
|
|
type: "memory" | "web"
|
|
title?: string
|
|
url?: string
|
|
sourceId?: string
|
|
documentId?: string
|
|
space?: string
|
|
}
|
|
|
|
const FOOTNOTE_REFERENCE_RE = /\[\^([A-Za-z0-9_-]+)\](?!:)/g
|
|
const MARKDOWN_LINK_RE = /\]\((https?:\/\/[^\s)]+)(?:\s+"[^"]*")?\)/g
|
|
const AUTOLINK_RE = /<(https?:\/\/[^\s>]+)>/g
|
|
const REFERENCE_SECTION_RE =
|
|
/(?:^|\n)(?:---\s*\n)?#{1,6}\s+(?:sources|references|footnotes)\s*\n[\s\S]*$/i
|
|
|
|
function markdownUrls(markdown: string): string[] {
|
|
return [
|
|
...markdown.matchAll(MARKDOWN_LINK_RE),
|
|
...markdown.matchAll(AUTOLINK_RE),
|
|
]
|
|
.map((match) => match[1])
|
|
.filter((url): url is string => Boolean(url))
|
|
}
|
|
|
|
export function normalizeResearchMarkdownForDisplay(markdown: string): string {
|
|
const definitions = new Map<string, string>()
|
|
const bodyLines: string[] = []
|
|
const lines = markdown.replace(/\r\n?/g, "\n").split("\n")
|
|
|
|
for (let index = 0; index < lines.length; index++) {
|
|
const line = lines[index] ?? ""
|
|
const match = line.match(/^\[\^([A-Za-z0-9_-]+)\]:\s*(.*)$/)
|
|
if (!match?.[1]) {
|
|
bodyLines.push(line)
|
|
continue
|
|
}
|
|
|
|
const parts = [match[2] ?? ""]
|
|
while (/^(?:\t| {2,})\S/.test(lines[index + 1] ?? "")) {
|
|
index++
|
|
parts.push((lines[index] ?? "").trim())
|
|
}
|
|
definitions.set(match[1], parts.join(" ").trim())
|
|
}
|
|
|
|
const citationNumberByUrl = new Map<string, number>()
|
|
let nextCitationNumber = 1
|
|
const withInlineCitations = bodyLines
|
|
.join("\n")
|
|
.replace(FOOTNOTE_REFERENCE_RE, (reference, footnoteId: string) => {
|
|
const definition = definitions.get(footnoteId)
|
|
if (!definition) return reference
|
|
const urls = [...new Set(markdownUrls(definition))]
|
|
if (urls.length === 0) return reference
|
|
return urls
|
|
.map((url) => {
|
|
let number = citationNumberByUrl.get(url)
|
|
if (!number) {
|
|
number = nextCitationNumber++
|
|
citationNumberByUrl.set(url, number)
|
|
}
|
|
return `[${number}](${url})`
|
|
})
|
|
.join(" ")
|
|
})
|
|
|
|
return withInlineCitations
|
|
.replace(REFERENCE_SECTION_RE, "")
|
|
.replace(/(?:^|\n)---\s*$/, "")
|
|
.replace(/\n{3,}/g, "\n\n")
|
|
.trim()
|
|
}
|
|
|
|
export function formatResearchDuration(durationMs?: number): string | null {
|
|
if (
|
|
typeof durationMs !== "number" ||
|
|
!Number.isFinite(durationMs) ||
|
|
durationMs < 0
|
|
) {
|
|
return null
|
|
}
|
|
|
|
const totalSeconds = Math.max(1, Math.round(durationMs / 1000))
|
|
if (totalSeconds < 60) return `${totalSeconds}s`
|
|
|
|
const minutes = Math.floor(totalSeconds / 60)
|
|
const seconds = totalSeconds % 60
|
|
return seconds === 0 ? `${minutes}m` : `${minutes}m ${seconds}s`
|
|
}
|
|
|
|
export type NovaResearchEvent = {
|
|
id: string
|
|
sequence: number
|
|
type:
|
|
| "status"
|
|
| "assistant"
|
|
| "clarification"
|
|
| "plan"
|
|
| "tool"
|
|
| "source"
|
|
| "artifact"
|
|
| "error"
|
|
status: string | null
|
|
title: string | null
|
|
message: string | null
|
|
toolName: string | null
|
|
input: unknown
|
|
output: unknown
|
|
createdAt: string
|
|
}
|
|
|
|
export type NovaResearchRun = {
|
|
id: string
|
|
threadId: string
|
|
userMessageId: string
|
|
assistantMessageId: string
|
|
workflowInstanceId: string | null
|
|
query: string
|
|
model: string
|
|
reasoningEffort: "instant" | "thinking"
|
|
spaceMode: "auto" | "manual"
|
|
projectId: string
|
|
status: NovaResearchStatus
|
|
plan: NovaResearchPlan | null
|
|
sources: NovaResearchSource[]
|
|
reportTitle: string | null
|
|
reportMarkdown: string | null
|
|
reportDocumentId: string | null
|
|
failure?: NovaResearchFailure | null
|
|
/** @deprecated Legacy internal error detail. Keep for response compatibility only. */
|
|
error: string | null
|
|
toolCallCount: number
|
|
startedAt: string | null
|
|
completedAt: string | null
|
|
createdAt: string
|
|
updatedAt: string
|
|
events: NovaResearchEvent[]
|
|
}
|
|
|
|
export type NovaResearchPlanStepDisplayStatus =
|
|
| NovaResearchPlan["steps"][number]["status"]
|
|
| "failed"
|
|
| "cancelled"
|
|
|
|
const SAFE_RESEARCH_TOOL_FAILURE_MESSAGES = new Set([
|
|
"Web search could not be completed.",
|
|
"The webpage could not be read.",
|
|
"Memory retrieval could not be completed.",
|
|
"This research step could not be completed.",
|
|
])
|
|
|
|
function safeResearchIncidentId(
|
|
failure?: NovaResearchFailure | null,
|
|
): string | null {
|
|
const incidentId = failure?.incidentId?.trim()
|
|
return incidentId && /^[A-Za-z0-9_-]{1,64}$/.test(incidentId)
|
|
? incidentId
|
|
: null
|
|
}
|
|
|
|
export function researchToolFailureDescription(
|
|
event: NovaResearchEvent,
|
|
): string | null {
|
|
if (event.type !== "tool" || event.status !== "failed") return null
|
|
const output =
|
|
event.output && typeof event.output === "object"
|
|
? (event.output as { error?: unknown })
|
|
: null
|
|
const message = typeof output?.error === "string" ? output.error.trim() : ""
|
|
return SAFE_RESEARCH_TOOL_FAILURE_MESSAGES.has(message)
|
|
? message
|
|
: "This research tool could not be completed."
|
|
}
|
|
|
|
export function researchFailurePresentation(run: NovaResearchRun): {
|
|
title: string
|
|
description: string
|
|
incidentId: string | null
|
|
canRetryFinalization: boolean
|
|
retryLabel: string | null
|
|
} {
|
|
const failure = run.failure
|
|
const incidentId = safeResearchIncidentId(failure)
|
|
const retryable = failure?.retryable === true
|
|
|
|
switch (failure?.code?.trim().toUpperCase()) {
|
|
case "START_FAILED":
|
|
return {
|
|
title: "Research could not start",
|
|
description: "Research could not be started. Please try again.",
|
|
incidentId,
|
|
canRetryFinalization: false,
|
|
retryLabel: null,
|
|
}
|
|
case "RESEARCH_FAILED":
|
|
return {
|
|
title: "A research step failed",
|
|
description:
|
|
"Research stopped before the evidence review finished. You can run it again.",
|
|
incidentId,
|
|
canRetryFinalization: false,
|
|
retryLabel: null,
|
|
}
|
|
case "FINALIZATION_FAILED":
|
|
return {
|
|
title: "Report finalization failed",
|
|
description:
|
|
"The evidence was collected, but Nova could not finish a properly cited report. Your collected sources are preserved.",
|
|
incidentId,
|
|
canRetryFinalization: retryable,
|
|
retryLabel: retryable ? "Retry finalizing" : null,
|
|
}
|
|
case "PERSISTENCE_FAILED":
|
|
return {
|
|
title: "Saving the report failed",
|
|
description:
|
|
"The report was prepared but could not be saved. Your collected sources are preserved.",
|
|
incidentId,
|
|
canRetryFinalization: retryable,
|
|
retryLabel: retryable ? "Retry saving" : null,
|
|
}
|
|
default:
|
|
return {
|
|
title: "Research could not complete",
|
|
description:
|
|
"Nova stopped safely before producing a report. You can run the research again.",
|
|
incidentId,
|
|
canRetryFinalization: false,
|
|
retryLabel: null,
|
|
}
|
|
}
|
|
}
|
|
|
|
export function researchPlanStepDisplayStatus(
|
|
runStatus: NovaResearchStatus,
|
|
stepStatus: NovaResearchPlan["steps"][number]["status"],
|
|
): NovaResearchPlanStepDisplayStatus {
|
|
if (stepStatus === "complete" || stepStatus === "skipped") return stepStatus
|
|
if (runStatus === "failed") {
|
|
return stepStatus === "in_progress" ? "failed" : "skipped"
|
|
}
|
|
if (runStatus === "cancelled") return "cancelled"
|
|
if (runStatus === "completed") return "skipped"
|
|
return stepStatus
|
|
}
|
|
|
|
const RESEARCH_POLL_RETRY_DELAYS_MS = [1500, 2500, 4000, 7000, 12000, 15000]
|
|
|
|
export function researchPollDelayMs(
|
|
consecutiveFailures: number,
|
|
runStatus?: NovaResearchStatus,
|
|
): number {
|
|
if (!Number.isFinite(consecutiveFailures) || consecutiveFailures <= 0) {
|
|
if (runStatus === "awaiting_input") return 30_000
|
|
return RESEARCH_POLL_RETRY_DELAYS_MS[0] ?? 1500
|
|
}
|
|
const index = Math.min(
|
|
Math.floor(consecutiveFailures),
|
|
RESEARCH_POLL_RETRY_DELAYS_MS.length - 1,
|
|
)
|
|
return RESEARCH_POLL_RETRY_DELAYS_MS[index] ?? 15000
|
|
}
|
|
|
|
export type NovaResearchClarificationQuestion = {
|
|
id: string
|
|
question: string
|
|
options: Array<{ label: string; description?: string }>
|
|
allowOther?: boolean
|
|
}
|
|
|
|
export type NovaResearchClarificationRequest = {
|
|
id: string
|
|
intro?: string
|
|
questions: NovaResearchClarificationQuestion[]
|
|
}
|
|
|
|
export type NovaResearchClarificationAnswer = {
|
|
questionId: string
|
|
value: string
|
|
}
|
|
|
|
function isClarificationRequest(
|
|
value: unknown,
|
|
): value is NovaResearchClarificationRequest {
|
|
if (!value || typeof value !== "object") return false
|
|
const request = value as Partial<NovaResearchClarificationRequest>
|
|
return (
|
|
typeof request.id === "string" &&
|
|
Array.isArray(request.questions) &&
|
|
request.questions.length > 0 &&
|
|
request.questions.every(
|
|
(question) =>
|
|
question &&
|
|
typeof question.id === "string" &&
|
|
typeof question.question === "string" &&
|
|
Array.isArray(question.options) &&
|
|
question.options.length >= 2 &&
|
|
question.options.every(
|
|
(option) => option && typeof option.label === "string",
|
|
),
|
|
)
|
|
)
|
|
}
|
|
|
|
export function pendingResearchClarification(
|
|
run: NovaResearchRun,
|
|
): NovaResearchClarificationRequest | null {
|
|
if (run.status !== "awaiting_input") return null
|
|
for (let index = run.events.length - 1; index >= 0; index--) {
|
|
const event = run.events[index]
|
|
if (
|
|
event?.type === "clarification" &&
|
|
event.status === "pending" &&
|
|
isClarificationRequest(event.input)
|
|
) {
|
|
return event.input
|
|
}
|
|
}
|
|
return null
|
|
}
|
|
|
|
export function isActiveResearchRun(run: NovaResearchRun | null): boolean {
|
|
return (
|
|
run?.status === "queued" ||
|
|
run?.status === "running" ||
|
|
run?.status === "awaiting_input"
|
|
)
|
|
}
|