| 82 | 82 | | /** A message as the conversation reads it: oldest first. */ |
| 83 | 83 | | export type SaidRow = { id: string; author: string; kind: "text" | "card"; body: string; meta: string | null; deleted_at: string | null }; |
| 84 | 84 | | |
| 85 | + | /** The longest note of an earlier answer's reads, in characters. */ |
| 86 | + | const READS_NOTE_CHARS = 800; |
| 87 | + | |
| 85 | 88 | | /** |
| 89 | + | * What an earlier answer read in g1t, as a line put before its text when |
| 90 | + | * it is read back: a provider is given only the words of past answers, so |
| 91 | + | * without it the model takes an answer made from tool reads for one made |
| 92 | + | * up, and takes it back. Null when the answer read nothing. |
| 93 | + | */ |
| 94 | + | export function readsNote(steps: ChatAnswer["steps"]): string | null { |
| 95 | + | const tools = (steps ?? []).filter((step) => step.type === "tool" && typeof step.label === "string"); |
| 96 | + | if (tools.length === 0) return null; |
| 97 | + | const parts = tools.map((step) => { |
| 98 | + | const label = String(step.label).trim(); |
| 99 | + | const summary = typeof step.summary === "string" ? step.summary.trim() : ""; |
| 100 | + | if (step.status === "failed") return `${label} (failed${summary ? `: ${summary}` : ""})`; |
| 101 | + | if (step.status === "refused") return `${label} (not run)`; |
| 102 | + | return summary ? `${label}: ${summary}` : label; |
| 103 | + | }); |
| 104 | + | let note = `[g1t's tool reads for this answer: ${parts.join("; ")}]`; |
| 105 | + | if (note.length > READS_NOTE_CHARS) note = `${note.slice(0, READS_NOTE_CHARS - 2)}…]`; |
| 106 | + | return note; |
| 107 | + | } |
| 108 | + | |
| 109 | + | /** |
| 86 | 110 | | * What an answer reads: the direct message's messages after the latest |
| 87 | 111 | | * "New conversation" mark, the person's as `user` and g1t's answers as |
| 88 | 112 | | * `assistant`, oldest first, alternating, starting and ending with the |
| 89 | 113 | | * person, within Ask's limits (the newest kept). Answers that failed, the |
| 90 | 114 | | * set-up note and the answer being written are left out; several messages |
| 91 | | − | * in a row from one side are read as one. Empty when there is nothing to |
| 92 | | − | * answer. |
| 115 | + | * in a row from one side are read as one, and each answer starts with what |
| 116 | + | * it read in g1t ([`readsNote`]). Empty when there is nothing to answer. |
| 93 | 117 | | */ |
| 94 | 118 | | export function conversation(rows: readonly SaidRow[], answering: string | null = null): AskMessage[] { |
| 95 | 119 | | let start = 0; |
| ⋯ |
| 99 | 123 | | const turns: AskMessage[] = []; |
| 100 | 124 | | for (const row of rows.slice(start)) { |
| 101 | 125 | | if (row.deleted_at || row.kind !== "text" || row.id === answering) continue; |
| 102 | | − | const text = row.body.trim(); |
| 126 | + | let text = row.body.trim(); |
| 103 | 127 | | if (!text) continue; |
| 104 | 128 | | let role: AskMessage["role"]; |
| 105 | 129 | | if (row.author === G1T_KEY) { |
| 106 | 130 | | const answer = parseMeta(row.meta).answer; |
| 107 | 131 | | if (!answer || answer.setup || answer.state === "error" || answer.state === "thinking" || answer.state === "streaming") continue; |
| 108 | 132 | | role = "assistant"; |
| 133 | + | // First, so cutting a long answer short keeps it. |
| 134 | + | const reads = readsNote(answer.steps); |
| 135 | + | if (reads) text = `${reads}\n\n${text}`; |
| 109 | 136 | | } else if (row.author.startsWith("user:")) { |
| 110 | 137 | | role = "user"; |
| 111 | 138 | | } else continue; |