feat: update Makelore modules and conversations
This commit is contained in:
521
src/lib/opencode-session-state.ts
Normal file
521
src/lib/opencode-session-state.ts
Normal file
@@ -0,0 +1,521 @@
|
||||
import type {
|
||||
OpencodeNativePartKind,
|
||||
OpencodeNativeToolState,
|
||||
OpencodeNormalizedSessionMessage,
|
||||
OpencodeNormalizedSessionPart,
|
||||
OpencodeNormalizedToolState,
|
||||
OpencodeSessionExecutionState,
|
||||
OpencodeSessionTranscriptState,
|
||||
} from '@/types/opencode';
|
||||
|
||||
export interface OpenCodeEventEnvelope {
|
||||
type: string;
|
||||
payload: unknown;
|
||||
}
|
||||
|
||||
const PART_TYPES = new Set<OpencodeNativePartKind>([
|
||||
'text',
|
||||
'reasoning',
|
||||
'tool',
|
||||
'step-start',
|
||||
'step-finish',
|
||||
'retry',
|
||||
'subtask',
|
||||
'agent',
|
||||
'patch',
|
||||
'file',
|
||||
'snapshot',
|
||||
'compaction',
|
||||
]);
|
||||
|
||||
function asRecord(value: unknown): Record<string, unknown> | null {
|
||||
return value && typeof value === 'object' && !Array.isArray(value)
|
||||
? value as Record<string, unknown>
|
||||
: null;
|
||||
}
|
||||
|
||||
function nonEmptyString(value: unknown): string | undefined {
|
||||
return typeof value === 'string' && value.trim() ? value : undefined;
|
||||
}
|
||||
|
||||
function getSessionId(value: unknown): string | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) return undefined;
|
||||
return nonEmptyString(record?.sessionID)
|
||||
?? nonEmptyString(record?.sessionId)
|
||||
?? getSessionId(record?.part)
|
||||
?? getSessionId(record?.info)
|
||||
?? getSessionId(record?.message);
|
||||
}
|
||||
|
||||
function getDeltaText(value: unknown): string {
|
||||
if (typeof value === 'string') return value;
|
||||
|
||||
const record = asRecord(value);
|
||||
if (!record) return '';
|
||||
|
||||
for (const key of ['text', 'reasoning', 'reasoning_content', 'reasoning_text']) {
|
||||
const text = nonEmptyString(record[key]);
|
||||
if (text) return text;
|
||||
}
|
||||
|
||||
return '';
|
||||
}
|
||||
|
||||
function getMessageId(value: unknown): string | undefined {
|
||||
const record = asRecord(value);
|
||||
return nonEmptyString(record?.messageID)
|
||||
?? nonEmptyString(record?.messageId)
|
||||
?? nonEmptyString(asRecord(record?.info)?.id)
|
||||
?? nonEmptyString(asRecord(record?.message)?.id)
|
||||
?? nonEmptyString(asRecord(record?.part)?.messageID);
|
||||
}
|
||||
|
||||
function getPartId(value: unknown): string | undefined {
|
||||
const record = asRecord(value);
|
||||
return nonEmptyString(record?.partID)
|
||||
?? nonEmptyString(record?.partId)
|
||||
?? nonEmptyString(asRecord(record?.part)?.id)
|
||||
?? nonEmptyString(asRecord(record?.part)?.partID);
|
||||
}
|
||||
|
||||
function toMilliseconds(value: unknown): number | undefined {
|
||||
if (typeof value !== 'number' || !Number.isFinite(value)) return undefined;
|
||||
return Math.abs(value) < 1_000_000_000_000 ? value * 1_000 : value;
|
||||
}
|
||||
|
||||
function getTime(value: unknown): { start?: number; end?: number } | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) return undefined;
|
||||
const start = toMilliseconds(record.start);
|
||||
const end = toMilliseconds(record.end);
|
||||
return start === undefined && end === undefined ? undefined : { start, end };
|
||||
}
|
||||
|
||||
function stringifyDetail(value: unknown, limit = 8_000): string | undefined {
|
||||
if (value === undefined || value === null) return undefined;
|
||||
const text = typeof value === 'string' ? value : (() => {
|
||||
try {
|
||||
return JSON.stringify(value, null, 2);
|
||||
} catch {
|
||||
return String(value);
|
||||
}
|
||||
})();
|
||||
const trimmed = text.trim();
|
||||
if (!trimmed) return undefined;
|
||||
return trimmed.length > limit ? `${trimmed.slice(0, limit)}\n…` : trimmed;
|
||||
}
|
||||
|
||||
function normalizeToolState(value: unknown): OpencodeNormalizedToolState | undefined {
|
||||
const record = asRecord(value);
|
||||
if (!record) return undefined;
|
||||
const rawStatus = nonEmptyString(record.status);
|
||||
const status: OpencodeNativeToolState = rawStatus === 'pending'
|
||||
|| rawStatus === 'running'
|
||||
|| rawStatus === 'completed'
|
||||
|| rawStatus === 'error'
|
||||
? rawStatus
|
||||
: 'running';
|
||||
const time = getTime(record.time);
|
||||
return {
|
||||
status,
|
||||
...(record.input !== undefined ? { input: record.input } : {}),
|
||||
...(record.output !== undefined ? { output: record.output } : {}),
|
||||
...(record.error !== undefined ? { error: record.error } : {}),
|
||||
...(asRecord(record.metadata) ? { metadata: asRecord(record.metadata)! } : {}),
|
||||
...(time ? { time } : {}),
|
||||
};
|
||||
}
|
||||
|
||||
function normalizePartType(rawType: string): OpencodeNativePartKind {
|
||||
return PART_TYPES.has(rawType as OpencodeNativePartKind)
|
||||
? rawType as OpencodeNativePartKind
|
||||
: 'unknown';
|
||||
}
|
||||
|
||||
function pickPartRecord(value: unknown): Record<string, unknown> | null {
|
||||
const record = asRecord(value);
|
||||
return asRecord(record?.part) ?? record;
|
||||
}
|
||||
|
||||
function pickMessageInfo(value: unknown): Record<string, unknown> | null {
|
||||
const record = asRecord(value);
|
||||
return asRecord(record?.info) ?? asRecord(record?.message) ?? record;
|
||||
}
|
||||
|
||||
function normalizeMessageRole(value: unknown): string {
|
||||
const role = nonEmptyString(value);
|
||||
return role === 'user' || role === 'assistant' || role === 'system' || role === 'tool'
|
||||
? role
|
||||
: 'assistant';
|
||||
}
|
||||
|
||||
function normalizePart(
|
||||
input: unknown,
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
fallbackId?: string,
|
||||
): OpencodeNormalizedSessionPart | null {
|
||||
const record = pickPartRecord(input);
|
||||
if (!record) return null;
|
||||
const id = nonEmptyString(record.id) ?? nonEmptyString(record.partID) ?? fallbackId;
|
||||
const rawType = nonEmptyString(record.type) ?? 'unknown';
|
||||
if (!id) return null;
|
||||
|
||||
const type = normalizePartType(rawType);
|
||||
const state = normalizeToolState(record.state);
|
||||
const time = getTime(record.time) ?? state?.time;
|
||||
const text = typeof record.text === 'string'
|
||||
? record.text
|
||||
: typeof record.reasoning === 'string'
|
||||
? record.reasoning
|
||||
: typeof record.reasoning_content === 'string'
|
||||
? record.reasoning_content
|
||||
: typeof record.reasoning_text === 'string'
|
||||
? record.reasoning_text
|
||||
: undefined;
|
||||
const detail = stringifyDetail(
|
||||
type === 'tool'
|
||||
? state?.output ?? state?.error ?? state?.input
|
||||
: type === 'retry'
|
||||
? record.message ?? record.error ?? record
|
||||
: type === 'step-finish'
|
||||
? {
|
||||
reason: record.reason,
|
||||
cost: record.cost,
|
||||
tokens: record.tokens,
|
||||
}
|
||||
: type === 'text' || type === 'reasoning'
|
||||
? undefined
|
||||
: record,
|
||||
);
|
||||
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
messageID,
|
||||
type,
|
||||
rawType,
|
||||
...(text !== undefined ? { text } : {}),
|
||||
...(record.synthetic === true ? { synthetic: true } : {}),
|
||||
...(typeof record.tool === 'string' ? { tool: record.tool } : {}),
|
||||
...(typeof record.callID === 'string' ? { callID: record.callID } : {}),
|
||||
...(state ? { state } : {}),
|
||||
...(detail ? { detail } : {}),
|
||||
...(typeof record.parentID === 'string' ? { parentID: record.parentID } : {}),
|
||||
...(typeof record.sessionID === 'string' ? { childSessionID: record.sessionID } : {}),
|
||||
...(time ? { time } : {}),
|
||||
raw: record,
|
||||
};
|
||||
}
|
||||
|
||||
function createEmptyState(sessionID: string): OpencodeSessionTranscriptState {
|
||||
return {
|
||||
sessionID,
|
||||
status: 'idle',
|
||||
messagesById: {},
|
||||
messageOrder: [],
|
||||
partsById: {},
|
||||
partOrderByMessageId: {},
|
||||
};
|
||||
}
|
||||
|
||||
function ensureMessage(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
input: unknown,
|
||||
sessionID: string,
|
||||
messageID: string,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const info = pickMessageInfo(input) ?? {};
|
||||
const existing = state.messagesById[messageID];
|
||||
const next: OpencodeNormalizedSessionMessage = {
|
||||
id: messageID,
|
||||
sessionID,
|
||||
role: normalizeMessageRole(info.role),
|
||||
...(toMilliseconds(asRecord(info.time)?.created ?? info.createdAt) !== undefined
|
||||
? { createdAt: toMilliseconds(asRecord(info.time)?.created ?? info.createdAt) }
|
||||
: existing?.createdAt !== undefined ? { createdAt: existing.createdAt } : {}),
|
||||
...(toMilliseconds(asRecord(info.time)?.completed ?? info.completedAt) !== undefined
|
||||
? { completedAt: toMilliseconds(asRecord(info.time)?.completed ?? info.completedAt) }
|
||||
: existing?.completedAt !== undefined ? { completedAt: existing.completedAt } : {}),
|
||||
partIDs: existing?.partIDs ?? [],
|
||||
};
|
||||
return {
|
||||
...state,
|
||||
messagesById: { ...state.messagesById, [messageID]: next },
|
||||
messageOrder: state.messageOrder.includes(messageID)
|
||||
? state.messageOrder
|
||||
: [...state.messageOrder, messageID],
|
||||
partOrderByMessageId: state.partOrderByMessageId[messageID]
|
||||
? state.partOrderByMessageId
|
||||
: { ...state.partOrderByMessageId, [messageID]: [] },
|
||||
};
|
||||
}
|
||||
|
||||
function upsertPart(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
part: OpencodeNormalizedSessionPart,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const message = state.messagesById[part.messageID];
|
||||
const currentOrder = state.partOrderByMessageId[part.messageID] ?? [];
|
||||
return {
|
||||
...state,
|
||||
partsById: {
|
||||
...state.partsById,
|
||||
[part.id]: part,
|
||||
},
|
||||
messagesById: message
|
||||
? {
|
||||
...state.messagesById,
|
||||
[part.messageID]: {
|
||||
...message,
|
||||
partIDs: message.partIDs.includes(part.id)
|
||||
? message.partIDs
|
||||
: [...message.partIDs, part.id],
|
||||
},
|
||||
}
|
||||
: state.messagesById,
|
||||
partOrderByMessageId: {
|
||||
...state.partOrderByMessageId,
|
||||
[part.messageID]: currentOrder.includes(part.id)
|
||||
? currentOrder
|
||||
: [...currentOrder, part.id],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function removePart(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
partID: string,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const part = state.partsById[partID];
|
||||
if (!part) return state;
|
||||
const message = state.messagesById[part.messageID];
|
||||
const nextParts = { ...state.partsById };
|
||||
delete nextParts[partID];
|
||||
return {
|
||||
...state,
|
||||
partsById: nextParts,
|
||||
messagesById: message
|
||||
? {
|
||||
...state.messagesById,
|
||||
[part.messageID]: {
|
||||
...message,
|
||||
partIDs: message.partIDs.filter((id) => id !== partID),
|
||||
},
|
||||
}
|
||||
: state.messagesById,
|
||||
partOrderByMessageId: {
|
||||
...state.partOrderByMessageId,
|
||||
[part.messageID]: (state.partOrderByMessageId[part.messageID] ?? [])
|
||||
.filter((id) => id !== partID),
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
function eventKey(event: OpenCodeEventEnvelope, sessionID?: string): string | undefined {
|
||||
const record = asRecord(event.payload);
|
||||
const sequence = typeof record?.sequence === 'number' && Number.isFinite(record.sequence)
|
||||
? String(record.sequence)
|
||||
: nonEmptyString(record?.sequence);
|
||||
const id = nonEmptyString(record?.eventID)
|
||||
?? nonEmptyString(record?.eventId)
|
||||
?? sequence
|
||||
// Message/Part ids identify the resource, not the event. They must not be
|
||||
// treated as a delta event id or every later chunk would be discarded.
|
||||
?? (event.type === 'message.part.delta' ? undefined : nonEmptyString(record?.id));
|
||||
return id ? `${sessionID ?? getSessionId(event.payload) ?? ''}:${event.type}:${id}` : undefined;
|
||||
}
|
||||
|
||||
function rememberEventKey(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
key: string | undefined,
|
||||
): OpencodeSessionTranscriptState {
|
||||
if (!key) return state;
|
||||
const previous = state.seenEventKeys ?? [];
|
||||
if (previous.includes(key)) return state;
|
||||
return {
|
||||
...state,
|
||||
lastEventKey: key,
|
||||
seenEventKeys: [...previous, key].slice(-512),
|
||||
};
|
||||
}
|
||||
|
||||
function applyDelta(
|
||||
state: OpencodeSessionTranscriptState,
|
||||
event: OpenCodeEventEnvelope,
|
||||
): OpencodeSessionTranscriptState {
|
||||
const record = asRecord(event.payload);
|
||||
const delta = getDeltaText(record?.delta);
|
||||
const sessionID = getSessionId(event.payload) ?? state.sessionID;
|
||||
const messageID = getMessageId(event.payload);
|
||||
const partID = getPartId(event.payload);
|
||||
if (!delta || !messageID || !partID) return state;
|
||||
const field = nonEmptyString(record?.field) ?? nonEmptyString(asRecord(record?.part)?.field);
|
||||
const signature = `${messageID}:${partID}:${field ?? 'text'}:${delta}`;
|
||||
// Without a runtime event identity, only suppress an immediately repeated
|
||||
// signature. With an identity, the reducer-level event cache is the source
|
||||
// of truth and equal consecutive chunks remain valid output.
|
||||
if (!eventKey(event, sessionID) && state.lastDeltaSignature === signature) return state;
|
||||
|
||||
const existing = state.partsById[partID];
|
||||
const isReasoning = field === 'reasoning'
|
||||
|| field === 'thinking'
|
||||
|| asRecord(record?.part)?.type === 'reasoning'
|
||||
|| existing?.type === 'reasoning';
|
||||
const nextPart: OpencodeNormalizedSessionPart = {
|
||||
...(existing ?? {
|
||||
id: partID,
|
||||
sessionID,
|
||||
messageID,
|
||||
type: isReasoning ? 'reasoning' : 'text',
|
||||
rawType: isReasoning ? 'reasoning' : 'text',
|
||||
}),
|
||||
text: `${existing?.text ?? ''}${delta}`,
|
||||
};
|
||||
return {
|
||||
...upsertPart(ensureMessage(state, record?.part ?? record, sessionID, messageID), nextPart),
|
||||
lastDeltaSignature: signature,
|
||||
};
|
||||
}
|
||||
|
||||
export function normalizeOpenCodeMessage(
|
||||
input: unknown,
|
||||
fallbackSessionID?: string,
|
||||
): OpencodeNormalizedSessionMessage | null {
|
||||
const record = asRecord(input);
|
||||
if (!record) return null;
|
||||
const info = pickMessageInfo(record);
|
||||
const sessionID = getSessionId(record) ?? fallbackSessionID;
|
||||
const id = nonEmptyString(info?.id) ?? nonEmptyString(record.id);
|
||||
if (!sessionID || !id) return null;
|
||||
const parts = Array.isArray(record.parts) ? record.parts : [];
|
||||
const normalizedParts = parts
|
||||
.map((part) => normalizePart(part, sessionID, id))
|
||||
.filter((part): part is OpencodeNormalizedSessionPart => Boolean(part));
|
||||
return {
|
||||
id,
|
||||
sessionID,
|
||||
role: normalizeMessageRole(info?.role),
|
||||
...(toMilliseconds(asRecord(info?.time)?.created ?? info?.createdAt) !== undefined
|
||||
? { createdAt: toMilliseconds(asRecord(info?.time)?.created ?? info?.createdAt) }
|
||||
: {}),
|
||||
...(toMilliseconds(asRecord(info?.time)?.completed ?? info?.completedAt) !== undefined
|
||||
? { completedAt: toMilliseconds(asRecord(info?.time)?.completed ?? info?.completedAt) }
|
||||
: {}),
|
||||
partIDs: normalizedParts.map((part) => part.id),
|
||||
};
|
||||
}
|
||||
|
||||
export function hydrateOpenCodeSession(
|
||||
sessionID: string,
|
||||
messages: unknown,
|
||||
status: OpencodeSessionExecutionState = 'idle',
|
||||
): OpencodeSessionTranscriptState {
|
||||
const state = createEmptyState(sessionID);
|
||||
if (!Array.isArray(messages)) return { ...state, status };
|
||||
let next = state;
|
||||
for (const messageInput of messages) {
|
||||
const message = normalizeOpenCodeMessage(messageInput, sessionID);
|
||||
if (!message) continue;
|
||||
next = ensureMessage(next, messageInput, sessionID, message.id);
|
||||
const record = asRecord(messageInput);
|
||||
const parts = Array.isArray(record?.parts) ? record.parts : [];
|
||||
for (const partInput of parts) {
|
||||
const part = normalizePart(partInput, sessionID, message.id);
|
||||
if (part) next = upsertPart(next, part);
|
||||
}
|
||||
}
|
||||
return { ...next, status };
|
||||
}
|
||||
|
||||
/** Rebuild the canonical transcript from the server's message/Part snapshot. */
|
||||
export function hydrateSessionSnapshot(
|
||||
sessionID: string,
|
||||
messages: unknown,
|
||||
status: OpencodeSessionExecutionState = 'idle',
|
||||
): OpencodeSessionTranscriptState {
|
||||
return hydrateOpenCodeSession(sessionID, messages, status);
|
||||
}
|
||||
|
||||
export function reduceOpenCodeEvent(
|
||||
current: OpencodeSessionTranscriptState | undefined,
|
||||
event: OpenCodeEventEnvelope,
|
||||
): OpencodeSessionTranscriptState | undefined {
|
||||
const sessionID = getSessionId(event.payload) ?? current?.sessionID;
|
||||
if (!sessionID) return current;
|
||||
if (current && current.sessionID !== sessionID) return current;
|
||||
let state = current ?? createEmptyState(sessionID);
|
||||
const key = eventKey(event, sessionID);
|
||||
if (key && state.seenEventKeys?.includes(key)) return state;
|
||||
state = rememberEventKey(state, key);
|
||||
|
||||
if (event.type === 'message.part.delta') {
|
||||
return applyDelta(state, event);
|
||||
}
|
||||
|
||||
if (event.type === 'message.part.removed') {
|
||||
const partID = getPartId(event.payload);
|
||||
return partID ? removePart(state, partID) : state;
|
||||
}
|
||||
|
||||
if (event.type === 'message.updated') {
|
||||
const messageInput = asRecord(event.payload)?.message ?? event.payload;
|
||||
const message = normalizeOpenCodeMessage(messageInput, sessionID);
|
||||
if (!message) return state;
|
||||
let next = ensureMessage(state, messageInput, sessionID, message.id);
|
||||
const parts = Array.isArray(asRecord(messageInput)?.parts)
|
||||
? asRecord(messageInput)?.parts as unknown[]
|
||||
: [];
|
||||
for (const partInput of parts) {
|
||||
const part = normalizePart(partInput, sessionID, message.id);
|
||||
if (part) next = upsertPart(next, part);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
if (event.type === 'message.part.updated') {
|
||||
const record = asRecord(event.payload);
|
||||
const part = normalizePart(record?.part ?? event.payload, sessionID, getMessageId(event.payload) ?? '');
|
||||
const messageID = part?.messageID || getMessageId(event.payload);
|
||||
if (!part || !messageID) return state;
|
||||
const withMessage = ensureMessage(state, record?.message ?? record?.part ?? record, sessionID, messageID);
|
||||
return upsertPart(withMessage, { ...part, messageID });
|
||||
}
|
||||
|
||||
if (event.type === 'session.status') {
|
||||
const type = nonEmptyString(asRecord(event.payload)?.status && asRecord(asRecord(event.payload)?.status)?.type)
|
||||
?? nonEmptyString(asRecord(event.payload)?.type);
|
||||
return {
|
||||
...state,
|
||||
status: type === 'busy' || type === 'retry' || type === 'idle' ? type : state.status,
|
||||
};
|
||||
}
|
||||
if (event.type === 'session.idle' || event.type === 'session.compacted') {
|
||||
return { ...state, status: 'idle' };
|
||||
}
|
||||
if (event.type === 'session.error') {
|
||||
const payload = asRecord(event.payload);
|
||||
const error = payload?.error;
|
||||
return {
|
||||
...state,
|
||||
status: 'idle',
|
||||
lastError: stringifyDetail(error ?? payload?.message ?? payload?.errorMessage, 2_000),
|
||||
};
|
||||
}
|
||||
return state;
|
||||
}
|
||||
|
||||
export function getOrderedSessionParts(
|
||||
state: OpencodeSessionTranscriptState | undefined,
|
||||
messageID: string,
|
||||
): OpencodeNormalizedSessionPart[] {
|
||||
if (!state) return [];
|
||||
return (state.partOrderByMessageId[messageID] ?? [])
|
||||
.map((partID) => state.partsById[partID])
|
||||
.filter((part): part is OpencodeNormalizedSessionPart => Boolean(part));
|
||||
}
|
||||
|
||||
export function getSessionPartCount(state: OpencodeSessionTranscriptState | undefined): number {
|
||||
return state ? Object.keys(state.partsById).length : 0;
|
||||
}
|
||||
Reference in New Issue
Block a user