@ottocode/server 0.1.173
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +42 -0
- package/src/events/bus.ts +43 -0
- package/src/events/types.ts +32 -0
- package/src/index.ts +281 -0
- package/src/openapi/helpers.ts +64 -0
- package/src/openapi/paths/ask.ts +70 -0
- package/src/openapi/paths/config.ts +218 -0
- package/src/openapi/paths/files.ts +72 -0
- package/src/openapi/paths/git.ts +457 -0
- package/src/openapi/paths/messages.ts +92 -0
- package/src/openapi/paths/sessions.ts +90 -0
- package/src/openapi/paths/setu.ts +154 -0
- package/src/openapi/paths/stream.ts +26 -0
- package/src/openapi/paths/terminals.ts +226 -0
- package/src/openapi/schemas.ts +345 -0
- package/src/openapi/spec.ts +49 -0
- package/src/presets.ts +85 -0
- package/src/routes/ask.ts +113 -0
- package/src/routes/auth.ts +592 -0
- package/src/routes/branch.ts +106 -0
- package/src/routes/config/agents.ts +44 -0
- package/src/routes/config/cwd.ts +21 -0
- package/src/routes/config/defaults.ts +45 -0
- package/src/routes/config/index.ts +16 -0
- package/src/routes/config/main.ts +73 -0
- package/src/routes/config/models.ts +139 -0
- package/src/routes/config/providers.ts +46 -0
- package/src/routes/config/utils.ts +120 -0
- package/src/routes/files.ts +218 -0
- package/src/routes/git/branch.ts +75 -0
- package/src/routes/git/commit.ts +209 -0
- package/src/routes/git/diff.ts +137 -0
- package/src/routes/git/index.ts +18 -0
- package/src/routes/git/push.ts +160 -0
- package/src/routes/git/schemas.ts +48 -0
- package/src/routes/git/staging.ts +208 -0
- package/src/routes/git/status.ts +83 -0
- package/src/routes/git/types.ts +31 -0
- package/src/routes/git/utils.ts +249 -0
- package/src/routes/openapi.ts +6 -0
- package/src/routes/research.ts +392 -0
- package/src/routes/root.ts +5 -0
- package/src/routes/session-approval.ts +63 -0
- package/src/routes/session-files.ts +387 -0
- package/src/routes/session-messages.ts +170 -0
- package/src/routes/session-stream.ts +61 -0
- package/src/routes/sessions.ts +814 -0
- package/src/routes/setu.ts +346 -0
- package/src/routes/terminals.ts +227 -0
- package/src/runtime/agent/registry.ts +351 -0
- package/src/runtime/agent/runner-reasoning.ts +108 -0
- package/src/runtime/agent/runner-setup.ts +257 -0
- package/src/runtime/agent/runner.ts +375 -0
- package/src/runtime/agent-registry.ts +6 -0
- package/src/runtime/ask/service.ts +369 -0
- package/src/runtime/context/environment.ts +202 -0
- package/src/runtime/debug/index.ts +117 -0
- package/src/runtime/debug/state.ts +140 -0
- package/src/runtime/errors/api-error.ts +192 -0
- package/src/runtime/errors/handling.ts +199 -0
- package/src/runtime/message/compaction-auto.ts +154 -0
- package/src/runtime/message/compaction-context.ts +101 -0
- package/src/runtime/message/compaction-detect.ts +26 -0
- package/src/runtime/message/compaction-limits.ts +37 -0
- package/src/runtime/message/compaction-mark.ts +111 -0
- package/src/runtime/message/compaction-prune.ts +75 -0
- package/src/runtime/message/compaction.ts +21 -0
- package/src/runtime/message/history-builder.ts +266 -0
- package/src/runtime/message/service.ts +468 -0
- package/src/runtime/message/tool-history-tracker.ts +204 -0
- package/src/runtime/prompt/builder.ts +167 -0
- package/src/runtime/provider/anthropic.ts +50 -0
- package/src/runtime/provider/copilot.ts +12 -0
- package/src/runtime/provider/google.ts +8 -0
- package/src/runtime/provider/index.ts +60 -0
- package/src/runtime/provider/moonshot.ts +8 -0
- package/src/runtime/provider/oauth-adapter.ts +237 -0
- package/src/runtime/provider/openai.ts +18 -0
- package/src/runtime/provider/opencode.ts +7 -0
- package/src/runtime/provider/openrouter.ts +7 -0
- package/src/runtime/provider/selection.ts +118 -0
- package/src/runtime/provider/setu.ts +126 -0
- package/src/runtime/provider/zai.ts +16 -0
- package/src/runtime/session/branch.ts +280 -0
- package/src/runtime/session/db-operations.ts +285 -0
- package/src/runtime/session/manager.ts +99 -0
- package/src/runtime/session/queue.ts +243 -0
- package/src/runtime/stream/abort-handler.ts +65 -0
- package/src/runtime/stream/error-handler.ts +371 -0
- package/src/runtime/stream/finish-handler.ts +101 -0
- package/src/runtime/stream/handlers.ts +5 -0
- package/src/runtime/stream/step-finish.ts +93 -0
- package/src/runtime/stream/types.ts +25 -0
- package/src/runtime/tools/approval.ts +180 -0
- package/src/runtime/tools/context.ts +83 -0
- package/src/runtime/tools/mapping.ts +154 -0
- package/src/runtime/tools/setup.ts +44 -0
- package/src/runtime/topup/manager.ts +110 -0
- package/src/runtime/utils/cwd.ts +69 -0
- package/src/runtime/utils/token.ts +35 -0
- package/src/tools/adapter.ts +634 -0
- package/src/tools/database/get-parent-session.ts +183 -0
- package/src/tools/database/get-session-context.ts +161 -0
- package/src/tools/database/index.ts +42 -0
- package/src/tools/database/present-session-links.ts +47 -0
- package/src/tools/database/query-messages.ts +160 -0
- package/src/tools/database/query-sessions.ts +126 -0
- package/src/tools/database/search-history.ts +135 -0
- package/src/types/sql-imports.d.ts +5 -0
- package/sst-env.d.ts +8 -0
- package/tsconfig.json +7 -0
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
import type { getDb } from '@ottocode/database';
|
|
2
|
+
import { messages, messageParts } from '@ottocode/database/schema';
|
|
3
|
+
import { eq, asc, desc } from 'drizzle-orm';
|
|
4
|
+
|
|
5
|
+
export async function buildCompactionContext(
|
|
6
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
7
|
+
sessionId: string,
|
|
8
|
+
contextTokenLimit?: number,
|
|
9
|
+
): Promise<string> {
|
|
10
|
+
const allMessages = await db
|
|
11
|
+
.select()
|
|
12
|
+
.from(messages)
|
|
13
|
+
.where(eq(messages.sessionId, sessionId))
|
|
14
|
+
.orderBy(desc(messages.createdAt));
|
|
15
|
+
|
|
16
|
+
const maxChars = contextTokenLimit ? contextTokenLimit * 4 : 60000;
|
|
17
|
+
const recentBudget = Math.floor(maxChars * 0.65);
|
|
18
|
+
const olderBudget = maxChars - recentBudget;
|
|
19
|
+
|
|
20
|
+
const recentLines: string[] = [];
|
|
21
|
+
const olderLines: string[] = [];
|
|
22
|
+
let recentChars = 0;
|
|
23
|
+
let olderChars = 0;
|
|
24
|
+
let userTurns = 0;
|
|
25
|
+
let inRecent = true;
|
|
26
|
+
|
|
27
|
+
for (const msg of allMessages) {
|
|
28
|
+
if (msg.role === 'user') userTurns++;
|
|
29
|
+
if (userTurns > 3 && inRecent) inRecent = false;
|
|
30
|
+
|
|
31
|
+
const parts = await db
|
|
32
|
+
.select()
|
|
33
|
+
.from(messageParts)
|
|
34
|
+
.where(eq(messageParts.messageId, msg.id))
|
|
35
|
+
.orderBy(asc(messageParts.index));
|
|
36
|
+
|
|
37
|
+
for (const part of parts) {
|
|
38
|
+
if (part.compactedAt) continue;
|
|
39
|
+
|
|
40
|
+
try {
|
|
41
|
+
const content = JSON.parse(part.content ?? '{}');
|
|
42
|
+
|
|
43
|
+
if (part.type === 'text' && content.text) {
|
|
44
|
+
const text = `[${msg.role.toUpperCase()}]: ${content.text}`;
|
|
45
|
+
const limit = inRecent ? 3000 : 1000;
|
|
46
|
+
const line = text.slice(0, limit);
|
|
47
|
+
|
|
48
|
+
if (inRecent && recentChars < recentBudget) {
|
|
49
|
+
recentLines.unshift(line);
|
|
50
|
+
recentChars += line.length;
|
|
51
|
+
} else if (olderChars < olderBudget) {
|
|
52
|
+
olderLines.unshift(line);
|
|
53
|
+
olderChars += line.length;
|
|
54
|
+
}
|
|
55
|
+
} else if (part.type === 'tool_call' && content.name) {
|
|
56
|
+
if (inRecent && recentChars < recentBudget) {
|
|
57
|
+
const argsStr =
|
|
58
|
+
typeof content.args === 'object'
|
|
59
|
+
? JSON.stringify(content.args).slice(0, 1000)
|
|
60
|
+
: '';
|
|
61
|
+
const line = `[TOOL ${content.name}]: ${argsStr}`;
|
|
62
|
+
recentLines.unshift(line);
|
|
63
|
+
recentChars += line.length;
|
|
64
|
+
} else if (olderChars < olderBudget) {
|
|
65
|
+
const line = `[TOOL ${content.name}]`;
|
|
66
|
+
olderLines.unshift(line);
|
|
67
|
+
olderChars += line.length;
|
|
68
|
+
}
|
|
69
|
+
} else if (part.type === 'tool_result' && content.result !== null) {
|
|
70
|
+
const resultStr =
|
|
71
|
+
typeof content.result === 'string'
|
|
72
|
+
? content.result
|
|
73
|
+
: JSON.stringify(content.result ?? '');
|
|
74
|
+
|
|
75
|
+
if (inRecent && recentChars < recentBudget) {
|
|
76
|
+
const line = `[RESULT]: ${resultStr.slice(0, 2000)}`;
|
|
77
|
+
recentLines.unshift(line);
|
|
78
|
+
recentChars += line.length;
|
|
79
|
+
} else if (olderChars < olderBudget) {
|
|
80
|
+
const line = `[RESULT]: ${resultStr.slice(0, 150)}...`;
|
|
81
|
+
olderLines.unshift(line);
|
|
82
|
+
olderChars += line.length;
|
|
83
|
+
}
|
|
84
|
+
}
|
|
85
|
+
} catch {}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
if (olderChars >= olderBudget) break;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
const result: string[] = [];
|
|
92
|
+
if (olderLines.length > 0) {
|
|
93
|
+
result.push('[...older conversation (tool data truncated)...]');
|
|
94
|
+
result.push(...olderLines);
|
|
95
|
+
result.push('');
|
|
96
|
+
result.push('[--- Recent conversation (full detail) ---]');
|
|
97
|
+
}
|
|
98
|
+
result.push(...recentLines);
|
|
99
|
+
|
|
100
|
+
return result.join('\n');
|
|
101
|
+
}
|
|
@@ -0,0 +1,26 @@
|
|
|
1
|
+
export function isCompactCommand(content: string): boolean {
|
|
2
|
+
const trimmed = content.trim().toLowerCase();
|
|
3
|
+
return trimmed === '/compact';
|
|
4
|
+
}
|
|
5
|
+
|
|
6
|
+
export function getCompactionSystemPrompt(): string {
|
|
7
|
+
return `
|
|
8
|
+
The conversation context is being compacted. The provided context is structured with
|
|
9
|
+
RECENT conversation in full detail at the end, and OLDER conversation (with truncated tool data) at the start.
|
|
10
|
+
|
|
11
|
+
Generate a comprehensive summary that captures:
|
|
12
|
+
|
|
13
|
+
1. **Current State**: What was the most recent task? What is the current state of the work RIGHT NOW?
|
|
14
|
+
2. **Key Changes Made**: What files were created, modified, or deleted? Summarize recent code changes.
|
|
15
|
+
3. **Main Goals**: What is the user trying to accomplish overall?
|
|
16
|
+
4. **Important Decisions**: What approaches or solutions were chosen and why?
|
|
17
|
+
5. **Pending Work**: What remains to be done? Any known issues or blockers?
|
|
18
|
+
6. **Critical Context**: Any gotchas, errors encountered, or important details for continuing.
|
|
19
|
+
|
|
20
|
+
IMPORTANT: Prioritize the RECENT conversation. The summary must allow seamless continuation
|
|
21
|
+
of work. Focus on what was just done and what comes next — not the early parts of the conversation.
|
|
22
|
+
|
|
23
|
+
Format your response as a clear, structured summary. Start with "📦 **Context Compacted**" header.
|
|
24
|
+
Keep under 2000 characters but be thorough. This summary will replace detailed tool history.
|
|
25
|
+
`;
|
|
26
|
+
}
|
|
@@ -0,0 +1,37 @@
|
|
|
1
|
+
import { catalog, getModelInfo } from '@ottocode/sdk';
|
|
2
|
+
import type { ProviderId } from '@ottocode/sdk';
|
|
3
|
+
|
|
4
|
+
export const PRUNE_PROTECT = 40_000;
|
|
5
|
+
|
|
6
|
+
export function estimateTokens(text: string): number {
|
|
7
|
+
return Math.max(0, Math.round((text || '').length / 4));
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
export interface ModelLimits {
|
|
11
|
+
context: number;
|
|
12
|
+
output: number;
|
|
13
|
+
}
|
|
14
|
+
|
|
15
|
+
export function getModelLimits(
|
|
16
|
+
provider: string,
|
|
17
|
+
model: string,
|
|
18
|
+
): ModelLimits | null {
|
|
19
|
+
const info = getModelInfo(provider as ProviderId, model);
|
|
20
|
+
if (info?.limit?.context && info?.limit?.output) {
|
|
21
|
+
return { context: info.limit.context, output: info.limit.output };
|
|
22
|
+
}
|
|
23
|
+
for (const key of Object.keys(catalog) as ProviderId[]) {
|
|
24
|
+
const entry = catalog[key];
|
|
25
|
+
const m = entry?.models?.find((x) => x.id === model);
|
|
26
|
+
if (m?.limit?.context && m?.limit?.output) {
|
|
27
|
+
return { context: m.limit.context, output: m.limit.output };
|
|
28
|
+
}
|
|
29
|
+
}
|
|
30
|
+
return null;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
export function isCompacted(part: { compactedAt?: number | null }): boolean {
|
|
34
|
+
return !!part.compactedAt;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export const COMPACTED_PLACEHOLDER = '[Compacted]';
|
|
@@ -0,0 +1,111 @@
|
|
|
1
|
+
import type { getDb } from '@ottocode/database';
|
|
2
|
+
import { messages, messageParts } from '@ottocode/database/schema';
|
|
3
|
+
import { eq, asc, and, lt } from 'drizzle-orm';
|
|
4
|
+
import { debugLog } from '../debug/index.ts';
|
|
5
|
+
import { estimateTokens, PRUNE_PROTECT } from './compaction-limits.ts';
|
|
6
|
+
|
|
7
|
+
const PROTECTED_TOOLS = ['skill'];
|
|
8
|
+
|
|
9
|
+
export async function markSessionCompacted(
|
|
10
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
11
|
+
sessionId: string,
|
|
12
|
+
compactMessageId: string,
|
|
13
|
+
): Promise<{ compacted: number; saved: number }> {
|
|
14
|
+
debugLog(`[compaction] Marking session ${sessionId} as compacted`);
|
|
15
|
+
|
|
16
|
+
const compactMsg = await db
|
|
17
|
+
.select()
|
|
18
|
+
.from(messages)
|
|
19
|
+
.where(eq(messages.id, compactMessageId))
|
|
20
|
+
.limit(1);
|
|
21
|
+
|
|
22
|
+
if (!compactMsg.length) {
|
|
23
|
+
debugLog('[compaction] Compact message not found');
|
|
24
|
+
return { compacted: 0, saved: 0 };
|
|
25
|
+
}
|
|
26
|
+
|
|
27
|
+
const cutoffTime = compactMsg[0].createdAt;
|
|
28
|
+
|
|
29
|
+
const oldMessages = await db
|
|
30
|
+
.select()
|
|
31
|
+
.from(messages)
|
|
32
|
+
.where(
|
|
33
|
+
and(
|
|
34
|
+
eq(messages.sessionId, sessionId),
|
|
35
|
+
lt(messages.createdAt, cutoffTime),
|
|
36
|
+
),
|
|
37
|
+
)
|
|
38
|
+
.orderBy(asc(messages.createdAt));
|
|
39
|
+
|
|
40
|
+
type PartInfo = { id: string; tokens: number };
|
|
41
|
+
const allToolParts: PartInfo[] = [];
|
|
42
|
+
let totalToolTokens = 0;
|
|
43
|
+
|
|
44
|
+
for (const msg of oldMessages) {
|
|
45
|
+
const parts = await db
|
|
46
|
+
.select()
|
|
47
|
+
.from(messageParts)
|
|
48
|
+
.where(eq(messageParts.messageId, msg.id))
|
|
49
|
+
.orderBy(asc(messageParts.index));
|
|
50
|
+
|
|
51
|
+
for (const part of parts) {
|
|
52
|
+
if (part.type !== 'tool_call' && part.type !== 'tool_result') continue;
|
|
53
|
+
if (part.toolName && PROTECTED_TOOLS.includes(part.toolName)) continue;
|
|
54
|
+
if (part.compactedAt) continue;
|
|
55
|
+
|
|
56
|
+
let content: { result?: unknown; args?: unknown };
|
|
57
|
+
try {
|
|
58
|
+
content = JSON.parse(part.content ?? '{}');
|
|
59
|
+
} catch {
|
|
60
|
+
continue;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
const contentStr =
|
|
64
|
+
part.type === 'tool_result'
|
|
65
|
+
? typeof content.result === 'string'
|
|
66
|
+
? content.result
|
|
67
|
+
: JSON.stringify(content.result ?? '')
|
|
68
|
+
: JSON.stringify(content.args ?? '');
|
|
69
|
+
|
|
70
|
+
const tokens = estimateTokens(contentStr);
|
|
71
|
+
totalToolTokens += tokens;
|
|
72
|
+
allToolParts.push({ id: part.id, tokens });
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
const tokensToFree = Math.max(0, totalToolTokens - PRUNE_PROTECT);
|
|
77
|
+
|
|
78
|
+
const toCompact: PartInfo[] = [];
|
|
79
|
+
let freedTokens = 0;
|
|
80
|
+
|
|
81
|
+
for (const part of allToolParts) {
|
|
82
|
+
if (freedTokens >= tokensToFree) break;
|
|
83
|
+
freedTokens += part.tokens;
|
|
84
|
+
toCompact.push(part);
|
|
85
|
+
}
|
|
86
|
+
|
|
87
|
+
debugLog(
|
|
88
|
+
`[compaction] Found ${toCompact.length} parts to compact (oldest first), saving ~${freedTokens} tokens`,
|
|
89
|
+
);
|
|
90
|
+
|
|
91
|
+
if (toCompact.length > 0) {
|
|
92
|
+
const compactedAt = Date.now();
|
|
93
|
+
|
|
94
|
+
for (const part of toCompact) {
|
|
95
|
+
try {
|
|
96
|
+
await db
|
|
97
|
+
.update(messageParts)
|
|
98
|
+
.set({ compactedAt })
|
|
99
|
+
.where(eq(messageParts.id, part.id));
|
|
100
|
+
} catch (err) {
|
|
101
|
+
debugLog(
|
|
102
|
+
`[compaction] Failed to mark part ${part.id}: ${err instanceof Error ? err.message : String(err)}`,
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
debugLog(`[compaction] Marked ${toCompact.length} parts as compacted`);
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
return { compacted: toCompact.length, saved: freedTokens };
|
|
111
|
+
}
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import type { getDb } from '@ottocode/database';
|
|
2
|
+
import { messages, messageParts } from '@ottocode/database/schema';
|
|
3
|
+
import { eq, desc } from 'drizzle-orm';
|
|
4
|
+
import { debugLog } from '../debug/index.ts';
|
|
5
|
+
import { estimateTokens, PRUNE_PROTECT } from './compaction-limits.ts';
|
|
6
|
+
|
|
7
|
+
const PROTECTED_TOOLS = ['skill'];
|
|
8
|
+
|
|
9
|
+
export async function pruneSession(
|
|
10
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
11
|
+
sessionId: string,
|
|
12
|
+
): Promise<{ pruned: number; saved: number }> {
|
|
13
|
+
debugLog(`[compaction] Auto-pruning session ${sessionId}`);
|
|
14
|
+
|
|
15
|
+
const allMessages = await db
|
|
16
|
+
.select()
|
|
17
|
+
.from(messages)
|
|
18
|
+
.where(eq(messages.sessionId, sessionId))
|
|
19
|
+
.orderBy(desc(messages.createdAt));
|
|
20
|
+
|
|
21
|
+
let totalTokens = 0;
|
|
22
|
+
let prunedTokens = 0;
|
|
23
|
+
const toPrune: Array<{ id: string }> = [];
|
|
24
|
+
let turns = 0;
|
|
25
|
+
|
|
26
|
+
for (const msg of allMessages) {
|
|
27
|
+
if (msg.role === 'user') turns++;
|
|
28
|
+
if (turns < 2) continue;
|
|
29
|
+
|
|
30
|
+
const parts = await db
|
|
31
|
+
.select()
|
|
32
|
+
.from(messageParts)
|
|
33
|
+
.where(eq(messageParts.messageId, msg.id))
|
|
34
|
+
.orderBy(desc(messageParts.index));
|
|
35
|
+
|
|
36
|
+
for (const part of parts) {
|
|
37
|
+
if (part.type !== 'tool_result') continue;
|
|
38
|
+
if (part.toolName && PROTECTED_TOOLS.includes(part.toolName)) continue;
|
|
39
|
+
if (part.compactedAt) continue;
|
|
40
|
+
|
|
41
|
+
let content: { result?: unknown };
|
|
42
|
+
try {
|
|
43
|
+
content = JSON.parse(part.content ?? '{}');
|
|
44
|
+
} catch {
|
|
45
|
+
continue;
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
const estimate = estimateTokens(
|
|
49
|
+
typeof content.result === 'string'
|
|
50
|
+
? content.result
|
|
51
|
+
: JSON.stringify(content.result ?? ''),
|
|
52
|
+
);
|
|
53
|
+
totalTokens += estimate;
|
|
54
|
+
|
|
55
|
+
if (totalTokens > PRUNE_PROTECT) {
|
|
56
|
+
prunedTokens += estimate;
|
|
57
|
+
toPrune.push({ id: part.id });
|
|
58
|
+
}
|
|
59
|
+
}
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
if (toPrune.length > 0) {
|
|
63
|
+
const compactedAt = Date.now();
|
|
64
|
+
for (const part of toPrune) {
|
|
65
|
+
try {
|
|
66
|
+
await db
|
|
67
|
+
.update(messageParts)
|
|
68
|
+
.set({ compactedAt })
|
|
69
|
+
.where(eq(messageParts.id, part.id));
|
|
70
|
+
} catch {}
|
|
71
|
+
}
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
return { pruned: toPrune.length, saved: prunedTokens };
|
|
75
|
+
}
|
|
@@ -0,0 +1,21 @@
|
|
|
1
|
+
export {
|
|
2
|
+
PRUNE_PROTECT,
|
|
3
|
+
estimateTokens,
|
|
4
|
+
type ModelLimits,
|
|
5
|
+
getModelLimits,
|
|
6
|
+
isCompacted,
|
|
7
|
+
COMPACTED_PLACEHOLDER,
|
|
8
|
+
} from './compaction-limits.ts';
|
|
9
|
+
|
|
10
|
+
export {
|
|
11
|
+
isCompactCommand,
|
|
12
|
+
getCompactionSystemPrompt,
|
|
13
|
+
} from './compaction-detect.ts';
|
|
14
|
+
|
|
15
|
+
export { buildCompactionContext } from './compaction-context.ts';
|
|
16
|
+
|
|
17
|
+
export { markSessionCompacted } from './compaction-mark.ts';
|
|
18
|
+
|
|
19
|
+
export { pruneSession } from './compaction-prune.ts';
|
|
20
|
+
|
|
21
|
+
export { performAutoCompaction } from './compaction-auto.ts';
|
|
@@ -0,0 +1,266 @@
|
|
|
1
|
+
import { convertToModelMessages, type ModelMessage, type UIMessage } from 'ai';
|
|
2
|
+
import type { getDb } from '@ottocode/database';
|
|
3
|
+
import { messages, messageParts } from '@ottocode/database/schema';
|
|
4
|
+
import { eq, asc } from 'drizzle-orm';
|
|
5
|
+
import { debugLog } from '../debug/index.ts';
|
|
6
|
+
import { ToolHistoryTracker } from './tool-history-tracker.ts';
|
|
7
|
+
|
|
8
|
+
/**
|
|
9
|
+
* Builds the conversation history for a session from the database,
|
|
10
|
+
* converting it to the format expected by the AI SDK.
|
|
11
|
+
*/
|
|
12
|
+
export async function buildHistoryMessages(
|
|
13
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
14
|
+
sessionId: string,
|
|
15
|
+
): Promise<ModelMessage[]> {
|
|
16
|
+
const rows = await db
|
|
17
|
+
.select()
|
|
18
|
+
.from(messages)
|
|
19
|
+
.where(eq(messages.sessionId, sessionId))
|
|
20
|
+
.orderBy(asc(messages.createdAt));
|
|
21
|
+
|
|
22
|
+
const ui: UIMessage[] = [];
|
|
23
|
+
const toolHistory = new ToolHistoryTracker();
|
|
24
|
+
|
|
25
|
+
for (const m of rows) {
|
|
26
|
+
if (m.role === 'assistant' && m.status !== 'complete') {
|
|
27
|
+
debugLog(
|
|
28
|
+
`[buildHistoryMessages] Skipping assistant message ${m.id} with status ${m.status} (current turn still in progress)`,
|
|
29
|
+
);
|
|
30
|
+
logPendingToolParts(db, m.id);
|
|
31
|
+
continue;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const parts = await db
|
|
35
|
+
.select()
|
|
36
|
+
.from(messageParts)
|
|
37
|
+
.where(eq(messageParts.messageId, m.id))
|
|
38
|
+
.orderBy(asc(messageParts.index));
|
|
39
|
+
|
|
40
|
+
if (m.role === 'user') {
|
|
41
|
+
const uparts: UIMessage['parts'] = [];
|
|
42
|
+
for (const p of parts) {
|
|
43
|
+
if (p.type === 'text') {
|
|
44
|
+
try {
|
|
45
|
+
const obj = JSON.parse(p.content ?? '{}');
|
|
46
|
+
const t = String(obj.text ?? '');
|
|
47
|
+
if (t) uparts.push({ type: 'text', text: t });
|
|
48
|
+
} catch {}
|
|
49
|
+
} else if (p.type === 'image') {
|
|
50
|
+
try {
|
|
51
|
+
const obj = JSON.parse(p.content ?? '{}') as {
|
|
52
|
+
data?: string;
|
|
53
|
+
mediaType?: string;
|
|
54
|
+
};
|
|
55
|
+
if (obj.data && obj.mediaType) {
|
|
56
|
+
uparts.push({
|
|
57
|
+
type: 'file',
|
|
58
|
+
mediaType: obj.mediaType,
|
|
59
|
+
url: `data:${obj.mediaType};base64,${obj.data}`,
|
|
60
|
+
} as never);
|
|
61
|
+
}
|
|
62
|
+
} catch {}
|
|
63
|
+
} else if (p.type === 'file') {
|
|
64
|
+
try {
|
|
65
|
+
const obj = JSON.parse(p.content ?? '{}') as {
|
|
66
|
+
type?: 'image' | 'pdf' | 'text';
|
|
67
|
+
name?: string;
|
|
68
|
+
data?: string;
|
|
69
|
+
mediaType?: string;
|
|
70
|
+
textContent?: string;
|
|
71
|
+
};
|
|
72
|
+
if (obj.type === 'text' && obj.textContent) {
|
|
73
|
+
uparts.push({
|
|
74
|
+
type: 'text',
|
|
75
|
+
text: `<file name="${obj.name || 'file'}">\n${obj.textContent}\n</file>`,
|
|
76
|
+
});
|
|
77
|
+
} else if (obj.type === 'pdf' && obj.data && obj.mediaType) {
|
|
78
|
+
uparts.push({
|
|
79
|
+
type: 'file',
|
|
80
|
+
mediaType: obj.mediaType,
|
|
81
|
+
url: `data:${obj.mediaType};base64,${obj.data}`,
|
|
82
|
+
} as never);
|
|
83
|
+
} else if (obj.type === 'image' && obj.data && obj.mediaType) {
|
|
84
|
+
uparts.push({
|
|
85
|
+
type: 'file',
|
|
86
|
+
mediaType: obj.mediaType,
|
|
87
|
+
url: `data:${obj.mediaType};base64,${obj.data}`,
|
|
88
|
+
} as never);
|
|
89
|
+
}
|
|
90
|
+
} catch {}
|
|
91
|
+
}
|
|
92
|
+
}
|
|
93
|
+
if (uparts.length) {
|
|
94
|
+
ui.push({ id: m.id, role: 'user', parts: uparts });
|
|
95
|
+
}
|
|
96
|
+
continue;
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
if (m.role === 'assistant') {
|
|
100
|
+
const assistantParts: UIMessage['parts'] = [];
|
|
101
|
+
const toolCalls: Array<{ name: string; callId: string; args: unknown }> =
|
|
102
|
+
[];
|
|
103
|
+
const toolResults: Array<{
|
|
104
|
+
name: string;
|
|
105
|
+
callId: string;
|
|
106
|
+
result: unknown;
|
|
107
|
+
}> = [];
|
|
108
|
+
|
|
109
|
+
for (const p of parts) {
|
|
110
|
+
if (p.type === 'reasoning') continue;
|
|
111
|
+
|
|
112
|
+
if (p.type === 'text') {
|
|
113
|
+
try {
|
|
114
|
+
const obj = JSON.parse(p.content ?? '{}');
|
|
115
|
+
const t = String(obj.text ?? '');
|
|
116
|
+
if (t) assistantParts.push({ type: 'text', text: t });
|
|
117
|
+
} catch {}
|
|
118
|
+
} else if (p.type === 'tool_call') {
|
|
119
|
+
// Skip compacted tool calls entirely
|
|
120
|
+
if (p.compactedAt) continue;
|
|
121
|
+
|
|
122
|
+
try {
|
|
123
|
+
const obj = JSON.parse(p.content ?? '{}') as {
|
|
124
|
+
name?: string;
|
|
125
|
+
callId?: string;
|
|
126
|
+
args?: unknown;
|
|
127
|
+
};
|
|
128
|
+
if (obj.callId && obj.name) {
|
|
129
|
+
toolCalls.push({
|
|
130
|
+
name: obj.name,
|
|
131
|
+
callId: obj.callId,
|
|
132
|
+
args: obj.args,
|
|
133
|
+
});
|
|
134
|
+
}
|
|
135
|
+
} catch {}
|
|
136
|
+
} else if (p.type === 'tool_result') {
|
|
137
|
+
// Skip compacted tool results entirely
|
|
138
|
+
if (p.compactedAt) continue;
|
|
139
|
+
|
|
140
|
+
try {
|
|
141
|
+
const obj = JSON.parse(p.content ?? '{}') as {
|
|
142
|
+
name?: string;
|
|
143
|
+
callId?: string;
|
|
144
|
+
result?: unknown;
|
|
145
|
+
};
|
|
146
|
+
if (obj.callId) {
|
|
147
|
+
toolResults.push({
|
|
148
|
+
name: obj.name ?? 'tool',
|
|
149
|
+
callId: obj.callId,
|
|
150
|
+
result: obj.result,
|
|
151
|
+
});
|
|
152
|
+
}
|
|
153
|
+
} catch {}
|
|
154
|
+
}
|
|
155
|
+
// Skip error parts in history
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
const toolResultsById = new Map(
|
|
159
|
+
toolResults.map((result) => [result.callId, result]),
|
|
160
|
+
);
|
|
161
|
+
|
|
162
|
+
for (const call of toolCalls) {
|
|
163
|
+
// Skip finish tool from history - it's internal loop control
|
|
164
|
+
if (call.name === 'finish') continue;
|
|
165
|
+
|
|
166
|
+
const toolType = `tool-${call.name}` as `tool-${string}`;
|
|
167
|
+
let result = toolResultsById.get(call.callId);
|
|
168
|
+
|
|
169
|
+
if (!result) {
|
|
170
|
+
// Synthesize a result for incomplete tool calls to preserve history
|
|
171
|
+
debugLog(
|
|
172
|
+
`[buildHistoryMessages] Synthesizing error result for incomplete tool call ${call.name}#${call.callId}`,
|
|
173
|
+
);
|
|
174
|
+
result = {
|
|
175
|
+
name: call.name,
|
|
176
|
+
callId: call.callId,
|
|
177
|
+
result:
|
|
178
|
+
'Error: The tool execution was interrupted or failed to return a result. You may need to retry this operation.',
|
|
179
|
+
};
|
|
180
|
+
}
|
|
181
|
+
|
|
182
|
+
const part = {
|
|
183
|
+
type: toolType,
|
|
184
|
+
state: 'output-available',
|
|
185
|
+
toolCallId: call.callId,
|
|
186
|
+
input: call.args,
|
|
187
|
+
output: (() => {
|
|
188
|
+
const r = result.result;
|
|
189
|
+
if (typeof r === 'string') return r;
|
|
190
|
+
try {
|
|
191
|
+
return JSON.stringify(r);
|
|
192
|
+
} catch {
|
|
193
|
+
return String(r);
|
|
194
|
+
}
|
|
195
|
+
})(),
|
|
196
|
+
};
|
|
197
|
+
|
|
198
|
+
toolHistory.register(part, {
|
|
199
|
+
toolName: call.name,
|
|
200
|
+
callId: call.callId,
|
|
201
|
+
args: call.args,
|
|
202
|
+
result: result.result,
|
|
203
|
+
});
|
|
204
|
+
|
|
205
|
+
assistantParts.push(part as never);
|
|
206
|
+
}
|
|
207
|
+
|
|
208
|
+
if (assistantParts.length) {
|
|
209
|
+
ui.push({ id: m.id, role: 'assistant', parts: assistantParts });
|
|
210
|
+
}
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return await convertToModelMessages(ui);
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
async function logPendingToolParts(
|
|
218
|
+
db: Awaited<ReturnType<typeof getDb>>,
|
|
219
|
+
messageId: string,
|
|
220
|
+
) {
|
|
221
|
+
try {
|
|
222
|
+
const parts = await db
|
|
223
|
+
.select()
|
|
224
|
+
.from(messageParts)
|
|
225
|
+
.where(eq(messageParts.messageId, messageId))
|
|
226
|
+
.orderBy(asc(messageParts.index));
|
|
227
|
+
|
|
228
|
+
const pendingCalls: string[] = [];
|
|
229
|
+
for (const part of parts) {
|
|
230
|
+
if (part.type !== 'tool_call') continue;
|
|
231
|
+
try {
|
|
232
|
+
const obj = JSON.parse(part.content ?? '{}') as {
|
|
233
|
+
name?: string;
|
|
234
|
+
callId?: string;
|
|
235
|
+
};
|
|
236
|
+
if (obj.name && obj.callId) {
|
|
237
|
+
const resultExists = parts.some((candidate) => {
|
|
238
|
+
if (candidate.type !== 'tool_result') return false;
|
|
239
|
+
try {
|
|
240
|
+
const parsed = JSON.parse(candidate.content ?? '{}') as {
|
|
241
|
+
callId?: string;
|
|
242
|
+
};
|
|
243
|
+
return parsed.callId === obj.callId;
|
|
244
|
+
} catch {
|
|
245
|
+
return false;
|
|
246
|
+
}
|
|
247
|
+
});
|
|
248
|
+
if (!resultExists) {
|
|
249
|
+
pendingCalls.push(`${obj.name}#${obj.callId}`);
|
|
250
|
+
}
|
|
251
|
+
}
|
|
252
|
+
} catch {}
|
|
253
|
+
}
|
|
254
|
+
if (pendingCalls.length) {
|
|
255
|
+
debugLog(
|
|
256
|
+
`[buildHistoryMessages] Pending tool calls for assistant message ${messageId}: ${pendingCalls.join(', ')}`,
|
|
257
|
+
);
|
|
258
|
+
}
|
|
259
|
+
} catch (err) {
|
|
260
|
+
debugLog(
|
|
261
|
+
`[buildHistoryMessages] Failed to inspect pending tool calls for ${messageId}: ${
|
|
262
|
+
err instanceof Error ? err.message : String(err)
|
|
263
|
+
}`,
|
|
264
|
+
);
|
|
265
|
+
}
|
|
266
|
+
}
|