tinywebui 0.1.0
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/LICENSE +661 -0
- package/README.md +586 -0
- package/bin/tinywebui.js +105 -0
- package/package.json +49 -0
- package/public/app.js +81 -0
- package/public/chat/attachments.js +304 -0
- package/public/chat/chat.js +282 -0
- package/public/chat/composer.js +289 -0
- package/public/chat/library.js +76 -0
- package/public/chat/outline.js +39 -0
- package/public/chat/queue.js +124 -0
- package/public/chat/sidebar.js +485 -0
- package/public/chat/stream.js +162 -0
- package/public/chat/tasks.js +36 -0
- package/public/chat/transcript.js +691 -0
- package/public/core/access.js +42 -0
- package/public/core/api.js +27 -0
- package/public/core/dom.js +13 -0
- package/public/core/id.js +7 -0
- package/public/core/md.js +131 -0
- package/public/core/state.js +31 -0
- package/public/favicon.svg +9 -0
- package/public/index.html +261 -0
- package/public/panels/admin.js +199 -0
- package/public/panels/automations.js +464 -0
- package/public/panels/panels.js +52 -0
- package/public/panels/settings.js +365 -0
- package/public/panels/usage.js +474 -0
- package/public/shared/attachment_note.js +23 -0
- package/public/shared/events.js +27 -0
- package/public/shared/features.js +21 -0
- package/public/shared/origins.js +8 -0
- package/public/shared/usage.js +59 -0
- package/public/styles.css +1455 -0
- package/public/themes/README.md +178 -0
- package/public/themes/cyber-grid/cyber-grid.css +59 -0
- package/public/themes/drivers/scene-kit.js +422 -0
- package/public/themes/fall-fairy/fall-fairy.css +221 -0
- package/public/themes/fall-fairy/fall-fairy.js +859 -0
- package/public/themes/manifest.json +6 -0
- package/public/themes/theme-loader.js +123 -0
- package/src/access/auth.js +228 -0
- package/src/access/auth_gate.js +67 -0
- package/src/access/policy.js +180 -0
- package/src/audit.js +7 -0
- package/src/automations/automation.js +136 -0
- package/src/automations/scheduler.js +147 -0
- package/src/chat/compact.js +242 -0
- package/src/chat/llm.js +448 -0
- package/src/chat/runs.js +180 -0
- package/src/chat/tool_executor.js +239 -0
- package/src/cli.js +478 -0
- package/src/config/approval.js +54 -0
- package/src/config/config.js +479 -0
- package/src/config/models.js +112 -0
- package/src/files/documents.js +91 -0
- package/src/files/images.js +62 -0
- package/src/harness/agent.js +115 -0
- package/src/harness/attribution.js +75 -0
- package/src/harness/checkpoint.js +96 -0
- package/src/harness/history.js +40 -0
- package/src/harness/prompt.js +93 -0
- package/src/harness/provider.js +430 -0
- package/src/http.js +95 -0
- package/src/log.js +24 -0
- package/src/mcp.js +405 -0
- package/src/retrieval/embedding.js +298 -0
- package/src/retrieval/retrieval.js +250 -0
- package/src/routes/admin.js +112 -0
- package/src/routes/auth.js +97 -0
- package/src/routes/automations.js +92 -0
- package/src/routes/chats.js +232 -0
- package/src/routes/documents.js +133 -0
- package/src/routes/library.js +51 -0
- package/src/routes/mcp.js +59 -0
- package/src/routes/settings.js +101 -0
- package/src/server.js +397 -0
- package/src/set-password.js +32 -0
- package/src/store/automations.js +119 -0
- package/src/store/chats.js +254 -0
- package/src/store/documents.js +256 -0
- package/src/store/embeddings.js +67 -0
- package/src/store/index.js +90 -0
- package/src/store/messages.js +93 -0
- package/src/store/schema.js +480 -0
- package/src/store/scope.js +43 -0
- package/src/store/search.js +56 -0
- package/src/store/turns.js +78 -0
- package/src/store/usage.js +327 -0
- package/src/store/users.js +102 -0
- package/src/store/wire.js +55 -0
- package/src/tools/ask_tool.js +93 -0
- package/src/tools/chat_search_tool.js +63 -0
- package/src/tools/context_tool.js +183 -0
- package/src/tools/document_tool.js +102 -0
- package/src/tools/task_tool.js +63 -0
|
@@ -0,0 +1,136 @@
|
|
|
1
|
+
import { CronExpressionParser } from 'cron-parser';
|
|
2
|
+
|
|
3
|
+
export function validateSchedule(cron, timezone, now = Date.now()) {
|
|
4
|
+
const tz = String(timezone || '').trim();
|
|
5
|
+
if (!tz) throw new Error('timezone is required');
|
|
6
|
+
try { new Intl.DateTimeFormat('en-US', { timeZone: tz }); }
|
|
7
|
+
catch { throw new Error(`invalid IANA timezone: ${tz}`); }
|
|
8
|
+
let nextRunAt;
|
|
9
|
+
try {
|
|
10
|
+
nextRunAt = CronExpressionParser.parse(String(cron || ''), {
|
|
11
|
+
currentDate: new Date(now), tz
|
|
12
|
+
}).next().getTime();
|
|
13
|
+
} catch (err) {
|
|
14
|
+
throw new Error(`invalid cron schedule: ${err.message}`);
|
|
15
|
+
}
|
|
16
|
+
return { cron: String(cron).trim(), timezone: tz, nextRunAt };
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
function cleanFields(input, { requireAll = false } = {}) {
|
|
20
|
+
const fields = {};
|
|
21
|
+
for (const key of ['name', 'prompt']) {
|
|
22
|
+
if (input[key] === undefined && !requireAll) continue;
|
|
23
|
+
const val = String(input[key] ?? '').trim();
|
|
24
|
+
if (!val) throw new Error(`${key} is required`);
|
|
25
|
+
if (val.length > (key === 'prompt' ? 12000 : 120)) throw new Error(`${key} is too long`);
|
|
26
|
+
fields[key] = val;
|
|
27
|
+
}
|
|
28
|
+
if (requireAll || (input.cron !== undefined && input.timezone !== undefined)) {
|
|
29
|
+
const cron = input.cron ?? '';
|
|
30
|
+
const timezone = input.timezone ?? '';
|
|
31
|
+
Object.assign(fields, validateSchedule(cron, timezone));
|
|
32
|
+
}
|
|
33
|
+
if (input.enabled !== undefined) fields.enabled = Boolean(input.enabled);
|
|
34
|
+
return fields;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function automationToolDef() {
|
|
38
|
+
return {
|
|
39
|
+
type: 'function',
|
|
40
|
+
function: {
|
|
41
|
+
name: 'manage_automation',
|
|
42
|
+
description: [
|
|
43
|
+
'Manage scheduled automations: a prompt that runs on its own, on a cron schedule, in this chat.',
|
|
44
|
+
'Use it when the user wants something done repeatedly or later without them ("every morning',
|
|
45
|
+
'summarise...", "check X each Friday"). For steps of the current request use manage_tasks instead.',
|
|
46
|
+
'',
|
|
47
|
+
'Each run starts fresh: it sees only its own prompt and the previous run\'s result, not this',
|
|
48
|
+
'conversation, and nobody is there to answer questions. Write `prompt` so it stands alone:',
|
|
49
|
+
'what to do, where, and what to report.',
|
|
50
|
+
'',
|
|
51
|
+
'Actions and what each needs:',
|
|
52
|
+
'- list: nothing. Returns every automation with its id, schedule and last result.',
|
|
53
|
+
'- create: name, prompt, cron, timezone. Starts running on schedule immediately.',
|
|
54
|
+
'- update: id, plus only the fields to change. cron and timezone are checked together.',
|
|
55
|
+
'- delete: id. Permanent.',
|
|
56
|
+
'- trigger: id. Runs it once now; queued if the chat is busy.',
|
|
57
|
+
'',
|
|
58
|
+
'An automation keeps running until someone stops it, so before create or delete, make sure',
|
|
59
|
+
'the user asked for exactly that (schedule, timezone, what it does). If any of those is unclear,',
|
|
60
|
+
'ask first. Pausing is update with enabled=false.'
|
|
61
|
+
].join('\n'),
|
|
62
|
+
parameters: {
|
|
63
|
+
type: 'object',
|
|
64
|
+
properties: {
|
|
65
|
+
action: { type: 'string', enum: ['list', 'create', 'update', 'delete', 'trigger'] },
|
|
66
|
+
id: { type: 'string', description: 'The automation\'s id, from list or create. For update, delete and trigger.' },
|
|
67
|
+
name: { type: 'string', description: 'Short label shown to the user, e.g. "Morning inbox summary". Up to 120 characters.' },
|
|
68
|
+
prompt: { type: 'string', description: 'The instruction each run carries out, written to stand alone (see above). Up to 12000 characters.' },
|
|
69
|
+
cron: { type: 'string', description: 'Five fields: minute hour day-of-month month day-of-week. "0 9 * * 1-5" is 09:00 on weekdays; "*/30 * * * *" is every 30 minutes.' },
|
|
70
|
+
timezone: { type: 'string', description: 'IANA timezone the cron is read in, e.g. "Europe/Vienna". Required with cron; use the user\'s timezone, and ask if you do not know it.' },
|
|
71
|
+
enabled: { type: 'boolean', description: 'false pauses the automation without deleting it; true resumes it. New automations are enabled.' }
|
|
72
|
+
},
|
|
73
|
+
required: ['action']
|
|
74
|
+
}
|
|
75
|
+
}
|
|
76
|
+
};
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
export async function manageAutomation(args, { store, chatId, triggerAutomation, unattended = false } = {}) {
|
|
80
|
+
const action = String(args?.action || '');
|
|
81
|
+
// A scheduled run has nobody watching it. Letting it start more runs is how
|
|
82
|
+
// one trigger becomes an endless self-requeueing loop, and how text injected
|
|
83
|
+
// through a tool result turns into a standing schedule. Adjusting or removing
|
|
84
|
+
// an existing automation stays allowed -- "stop once X is done" is legitimate.
|
|
85
|
+
if (unattended && (action === 'create' || action === 'trigger')) {
|
|
86
|
+
return `Error: "${action}" is not available inside a scheduled run. Tell the user in your result if a new automation or run is needed.`;
|
|
87
|
+
}
|
|
88
|
+
const chat = chatId ? store.chats.byId(chatId) : null;
|
|
89
|
+
const userId = chat?.user_id ?? null;
|
|
90
|
+
if (action === 'list') return JSON.stringify(store.automations.list(userId));
|
|
91
|
+
if (action === 'create') {
|
|
92
|
+
if (!chat) throw new Error('automation creation requires a current chat');
|
|
93
|
+
const fields = cleanFields(args, { requireAll: true });
|
|
94
|
+
const row = store.automations.create({ ...fields, chatId, source: 'model' }, userId);
|
|
95
|
+
return JSON.stringify(row);
|
|
96
|
+
}
|
|
97
|
+
if (!args?.id) throw new Error('id is required');
|
|
98
|
+
if (action === 'delete') return store.automations.delete(args.id, userId)
|
|
99
|
+
? `Deleted automation ${args.id}.` : 'Error: no such automation';
|
|
100
|
+
if (action === 'trigger') {
|
|
101
|
+
const automation = store.automations.get(args.id, userId);
|
|
102
|
+
if (!automation) throw new Error('no such automation');
|
|
103
|
+
if (!triggerAutomation) throw new Error('manual triggering is unavailable');
|
|
104
|
+
return JSON.stringify(await triggerAutomation(automation, userId));
|
|
105
|
+
}
|
|
106
|
+
if (action === 'update') {
|
|
107
|
+
const current = store.automations.get(args.id, userId);
|
|
108
|
+
if (!current) throw new Error('no such automation');
|
|
109
|
+
const fields = cleanFields(args);
|
|
110
|
+
if (args.cron !== undefined || args.timezone !== undefined) {
|
|
111
|
+
Object.assign(fields, validateSchedule(fields.cron ?? current.cron, fields.timezone ?? current.timezone));
|
|
112
|
+
}
|
|
113
|
+
const row = store.automations.update(args.id, fields, userId);
|
|
114
|
+
return row ? JSON.stringify(row) : 'Error: no such automation';
|
|
115
|
+
}
|
|
116
|
+
throw new Error('action must be list, create, update, delete, or trigger');
|
|
117
|
+
}
|
|
118
|
+
|
|
119
|
+
export function nextSchedule(cron, timezone, now = Date.now()) {
|
|
120
|
+
return validateSchedule(cron, timezone, now).nextRunAt;
|
|
121
|
+
}
|
|
122
|
+
|
|
123
|
+
export function runMessage(automation) {
|
|
124
|
+
const previous = automation.lastResult
|
|
125
|
+
? `\n\nPrevious run result (truncated):\n${automation.lastResult.slice(0, 2000)}` : '';
|
|
126
|
+
return [
|
|
127
|
+
`[Scheduled automation: ${automation.name}]`,
|
|
128
|
+
`Schedule: ${automation.cron} (${automation.timezone})`,
|
|
129
|
+
// Without this the model reads an ordinary user turn and may stop to ask a
|
|
130
|
+
// clarifying question that nobody will ever answer.
|
|
131
|
+
'This is an unattended run: no one is present to answer questions. Complete the task'
|
|
132
|
+
+ ' with sensible assumptions and state them, or report exactly what blocked it.',
|
|
133
|
+
'',
|
|
134
|
+
automation.prompt
|
|
135
|
+
].join('\n') + previous;
|
|
136
|
+
}
|
|
@@ -0,0 +1,147 @@
|
|
|
1
|
+
import { nextSchedule, runMessage } from './automation.js';
|
|
2
|
+
import { ORIGIN } from '../../public/shared/origins.js';
|
|
3
|
+
|
|
4
|
+
/**
|
|
5
|
+
* Runs automations: on their cron schedule, or on demand. A run needs its
|
|
6
|
+
* chat idle; a manual trigger for a busy chat waits in a per-chat queue and
|
|
7
|
+
* starts once the chat's current run ends (`drain`, which the run manager
|
|
8
|
+
* calls). Scheduled occurrences that find the chat busy are skipped, not
|
|
9
|
+
* queued, and occurrences missed while the process was down are not replayed.
|
|
10
|
+
*
|
|
11
|
+
* `app` supplies the store, runs, and modelFor/toolsFor/ownerFeatures.
|
|
12
|
+
*/
|
|
13
|
+
export function createScheduler(app) {
|
|
14
|
+
const { store, runs } = app;
|
|
15
|
+
const pendingManual = new Map();
|
|
16
|
+
let timer = null;
|
|
17
|
+
let stopped = false;
|
|
18
|
+
|
|
19
|
+
function launch(automation, runId, trigger = 'schedule') {
|
|
20
|
+
// Nobody is at the keyboard to be turned away, so a disabled or pending
|
|
21
|
+
// owner is checked here: their schedules stop the moment their access does.
|
|
22
|
+
if (automation.userId && (store.users.get(automation.userId)?.status !== 'approved' || !app.ownerFeatures(automation.userId).has('automations'))) {
|
|
23
|
+
store.automations.updateRun(runId, { status: 'skipped', finishedAt: Date.now(), error: 'Owner account is not active.' });
|
|
24
|
+
return true;
|
|
25
|
+
}
|
|
26
|
+
const chat = store.chats.get(automation.chatId, automation.userId);
|
|
27
|
+
if (!chat) {
|
|
28
|
+
store.automations.updateRun(runId, { status: 'failed', finishedAt: Date.now(), error: 'Target chat no longer exists.' });
|
|
29
|
+
return false;
|
|
30
|
+
}
|
|
31
|
+
if (runs.isRunning(chat.id)) return false;
|
|
32
|
+
// A snapshot, not a reference: the card still reads right after the
|
|
33
|
+
// automation is renamed or deleted.
|
|
34
|
+
const origin = {
|
|
35
|
+
type: ORIGIN.AUTOMATION, automationId: automation.id, runId, trigger,
|
|
36
|
+
name: automation.name, cron: automation.cron, timezone: automation.timezone, prompt: automation.prompt
|
|
37
|
+
};
|
|
38
|
+
const firstSeq = store.messages.add(chat.id, { role: 'user', content: runMessage(automation), origin });
|
|
39
|
+
store.automations.updateRun(runId, { status: 'running', startedAt: Date.now() });
|
|
40
|
+
const role = automation.userId ? store.users.get(automation.userId)?.role : null;
|
|
41
|
+
runs.start({
|
|
42
|
+
chat,
|
|
43
|
+
model: app.modelFor(automation.userId, role),
|
|
44
|
+
tools: app.toolsFor(app.ownerFeatures(automation.userId)),
|
|
45
|
+
historyFromSeq: firstSeq,
|
|
46
|
+
unattended: true,
|
|
47
|
+
onFinish: ({ ok, error, result }) => {
|
|
48
|
+
store.automations.updateRun(runId, {
|
|
49
|
+
status: ok ? 'completed' : 'failed', finishedAt: Date.now(),
|
|
50
|
+
result: String(result || '').slice(0, 200000), error: error ? String(error).slice(0, 1000) : null
|
|
51
|
+
});
|
|
52
|
+
arm();
|
|
53
|
+
}
|
|
54
|
+
});
|
|
55
|
+
return true;
|
|
56
|
+
}
|
|
57
|
+
|
|
58
|
+
async function trigger(automation, userId) {
|
|
59
|
+
const owned = store.automations.get(automation.id, userId);
|
|
60
|
+
if (!owned) throw new Error('no such automation');
|
|
61
|
+
const runId = store.automations.addRun(owned.id, Date.now(), 'queued', 'manual');
|
|
62
|
+
if (runs.isRunning(owned.chatId)) {
|
|
63
|
+
const queue = pendingManual.get(owned.chatId) || [];
|
|
64
|
+
queue.push({ automationId: owned.id, userId, runId });
|
|
65
|
+
pendingManual.set(owned.chatId, queue);
|
|
66
|
+
return { runId, status: 'queued', chatId: owned.chatId };
|
|
67
|
+
}
|
|
68
|
+
launch(owned, runId, 'manual');
|
|
69
|
+
return { runId, status: 'running', chatId: owned.chatId };
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
function drain(chatId) {
|
|
73
|
+
if (runs.isRunning(chatId)) return;
|
|
74
|
+
const queue = pendingManual.get(chatId);
|
|
75
|
+
if (!queue?.length) return;
|
|
76
|
+
const next = queue.shift();
|
|
77
|
+
if (!queue.length) pendingManual.delete(chatId);
|
|
78
|
+
const automation = store.automations.get(next.automationId, next.userId);
|
|
79
|
+
if (!automation) {
|
|
80
|
+
store.automations.updateRun(next.runId, { status: 'failed', finishedAt: Date.now(), error: 'Automation no longer exists.' });
|
|
81
|
+
queueMicrotask(() => drain(chatId));
|
|
82
|
+
return;
|
|
83
|
+
}
|
|
84
|
+
if (!launch(automation, next.runId, 'manual')) {
|
|
85
|
+
const remaining = pendingManual.get(chatId) || [];
|
|
86
|
+
remaining.unshift(next);
|
|
87
|
+
pendingManual.set(chatId, remaining);
|
|
88
|
+
return;
|
|
89
|
+
}
|
|
90
|
+
if (queue.length) pendingManual.set(chatId, queue);
|
|
91
|
+
}
|
|
92
|
+
|
|
93
|
+
function refresh() {
|
|
94
|
+
const now = Date.now();
|
|
95
|
+
for (const automation of store.automations.listAll()) {
|
|
96
|
+
if (!automation.enabled) continue;
|
|
97
|
+
// Recompute stale timestamps on startup or after changes without replaying
|
|
98
|
+
// occurrences missed while the process was down.
|
|
99
|
+
if (!automation.nextRunAt || automation.nextRunAt <= now) {
|
|
100
|
+
try { store.automations.update(automation.id, { nextRunAt: nextSchedule(automation.cron, automation.timezone, now) }, automation.userId); }
|
|
101
|
+
catch (err) { store.automations.update(automation.id, { enabled: false, lastStatus: `invalid schedule: ${err.message}` }, automation.userId); }
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function processDue() {
|
|
107
|
+
if (stopped) return;
|
|
108
|
+
const now = Date.now();
|
|
109
|
+
for (const automation of store.automations.due(now)) {
|
|
110
|
+
const scheduledAt = automation.nextRunAt;
|
|
111
|
+
const runId = store.automations.addRun(automation.id, scheduledAt, 'queued');
|
|
112
|
+
let nextRunAt;
|
|
113
|
+
try { nextRunAt = nextSchedule(automation.cron, automation.timezone, scheduledAt); }
|
|
114
|
+
catch (err) {
|
|
115
|
+
store.automations.update(automation.id, { enabled: false, lastStatus: `invalid schedule: ${err.message}`, nextRunAt: null }, automation.userId);
|
|
116
|
+
store.automations.updateRun(runId, { status: 'failed', finishedAt: now, error: err.message });
|
|
117
|
+
continue;
|
|
118
|
+
}
|
|
119
|
+
store.automations.update(automation.id, { nextRunAt }, automation.userId);
|
|
120
|
+
if (runs.isRunning(automation.chatId)) {
|
|
121
|
+
store.automations.updateRun(runId, { status: 'skipped', finishedAt: now, error: 'Target chat was already running.' });
|
|
122
|
+
continue;
|
|
123
|
+
}
|
|
124
|
+
launch(automation, runId);
|
|
125
|
+
}
|
|
126
|
+
arm();
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/** (Re)sets the timer for the next due automation. Call after any change. */
|
|
130
|
+
function arm() {
|
|
131
|
+
if (stopped) return;
|
|
132
|
+
if (timer) clearTimeout(timer);
|
|
133
|
+
refresh();
|
|
134
|
+
const next = store.automations.nextDue();
|
|
135
|
+
if (next == null) { timer = null; return; }
|
|
136
|
+
timer = setTimeout(processDue, Math.max(25, Math.min(2_147_000_000, next - Date.now())));
|
|
137
|
+
timer.unref?.();
|
|
138
|
+
}
|
|
139
|
+
|
|
140
|
+
function stop() {
|
|
141
|
+
stopped = true;
|
|
142
|
+
if (timer) clearTimeout(timer);
|
|
143
|
+
}
|
|
144
|
+
|
|
145
|
+
store.automations.recoverRuns();
|
|
146
|
+
return { arm, trigger, drain, stop };
|
|
147
|
+
}
|
|
@@ -0,0 +1,242 @@
|
|
|
1
|
+
import { textMap, EXPAND_CONTEXT } from '../tools/context_tool.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* Context compaction.
|
|
5
|
+
*
|
|
6
|
+
* The append-only rule is what makes prompt caching work: every provider caches
|
|
7
|
+
* on an exact prefix match, so rewriting an earlier message throws away the
|
|
8
|
+
* cache from that point on. But nothing ever leaving is how a four-message
|
|
9
|
+
* research chat reaches 150k tokens, because every tool result is replayed in
|
|
10
|
+
* full on every subsequent turn.
|
|
11
|
+
*
|
|
12
|
+
* The resolution is to rewrite rarely and then stop. When the prompt crosses a
|
|
13
|
+
* threshold we demote old tool results to stubs exactly once, write the stubs
|
|
14
|
+
* back to the store, and from then on that prefix is frozen and byte-identical
|
|
15
|
+
* again. One cache miss buys many cheap turns. The alternative -- ageing
|
|
16
|
+
* messages out every turn -- changes the prefix on every request and never
|
|
17
|
+
* caches anything.
|
|
18
|
+
*
|
|
19
|
+
* Two invariants everything here depends on:
|
|
20
|
+
*
|
|
21
|
+
* 1. Deterministic. A stub is a pure function of the artifact. No model call,
|
|
22
|
+
* no timestamp, no counter. It is also stored rather than recomputed, so a
|
|
23
|
+
* later change to this file cannot disturb a frozen prefix.
|
|
24
|
+
* 2. Tool-agnostic. Tool output is an opaque blob from an arbitrary MCP
|
|
25
|
+
* server. Nothing below may look at a tool's name or know its schema; the
|
|
26
|
+
* only structure we infer is generic (JSON, XML-ish, or neither).
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
export const DIGEST_HEAD = 400;
|
|
30
|
+
export const DIGEST_TAIL = 200;
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* A one-line shape hint, derived only from generic structure. Gives the model
|
|
34
|
+
* enough to judge whether re-reading is worth an expand_context call, without
|
|
35
|
+
* the stub pretending to summarise content it has not understood.
|
|
36
|
+
*/
|
|
37
|
+
function outline(text) {
|
|
38
|
+
const trimmed = text.trim();
|
|
39
|
+
|
|
40
|
+
if (trimmed.startsWith('{') || trimmed.startsWith('[')) {
|
|
41
|
+
try {
|
|
42
|
+
const parsed = JSON.parse(trimmed);
|
|
43
|
+
if (Array.isArray(parsed)) return `json array, ${parsed.length} item(s)`;
|
|
44
|
+
if (parsed && typeof parsed === 'object') {
|
|
45
|
+
const keys = Object.keys(parsed);
|
|
46
|
+
const shown = keys.slice(0, 12).join(', ');
|
|
47
|
+
return `json object, keys: ${shown}${keys.length > 12 ? `, +${keys.length - 12} more` : ''}`;
|
|
48
|
+
}
|
|
49
|
+
} catch { /* not JSON after all; fall through */ }
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// XML-ish covers a lot of MCP output without committing to a parser.
|
|
53
|
+
const tags = trimmed.match(/<([a-zA-Z][\w:.-]*)(?=[\s/>])/g);
|
|
54
|
+
if (tags && tags.length >= 2) {
|
|
55
|
+
const counts = new Map();
|
|
56
|
+
for (const t of tags) {
|
|
57
|
+
const name = t.slice(1);
|
|
58
|
+
counts.set(name, (counts.get(name) || 0) + 1);
|
|
59
|
+
}
|
|
60
|
+
// Sorted by count, then name, so the line is stable for identical input.
|
|
61
|
+
const top = [...counts.entries()]
|
|
62
|
+
.sort((a, b) => b[1] - a[1] || a[0].localeCompare(b[0]))
|
|
63
|
+
.slice(0, 8)
|
|
64
|
+
.map(([name, n]) => `${name}×${n}`);
|
|
65
|
+
return `tags: ${top.join(', ')}`;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
const lines = trimmed ? trimmed.split('\n').length : 0;
|
|
69
|
+
return `plain text, ${lines} line(s)`;
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
/**
|
|
73
|
+
* Builds the stub that replaces a tool result on the wire. Head and tail are
|
|
74
|
+
* kept verbatim because the ends of a tool result are where the useful framing
|
|
75
|
+
* usually is, and because a verbatim excerpt cannot hallucinate.
|
|
76
|
+
*/
|
|
77
|
+
export function digest(artifact, { head = DIGEST_HEAD, tail = DIGEST_TAIL } = {}) {
|
|
78
|
+
const text = artifact.content ?? '';
|
|
79
|
+
const len = text.length;
|
|
80
|
+
const id = artifact.id;
|
|
81
|
+
const name = artifact.tool_name || 'tool';
|
|
82
|
+
|
|
83
|
+
if (len <= head + tail) {
|
|
84
|
+
// Nothing to gain; keep it whole rather than emit a stub bigger than its source.
|
|
85
|
+
return text;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
const elided = len - head - tail;
|
|
89
|
+
// Only for prose: a JSON or XML outline already names its own structure, and
|
|
90
|
+
// the model greps those by key or tag rather than paging through them.
|
|
91
|
+
const shape = outline(text);
|
|
92
|
+
const map = shape.startsWith('plain text') ? textMap(text) : '';
|
|
93
|
+
return [
|
|
94
|
+
`[compacted: artifact ${id} · ${name} · ${len.toLocaleString('en-US')} chars · ${shape}]`,
|
|
95
|
+
...(map ? [map] : []),
|
|
96
|
+
text.slice(0, head),
|
|
97
|
+
`\n… ${elided.toLocaleString('en-US')} chars elided …\n`,
|
|
98
|
+
text.slice(len - tail),
|
|
99
|
+
`[Full output retained. Read it with ${EXPAND_CONTEXT}("${id}", grep=... or offset/limit).]`
|
|
100
|
+
].join('\n');
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/** What one image is counted as, in characters of text (about 1.5k tokens). */
|
|
104
|
+
export const IMAGE_CHARS = 6000;
|
|
105
|
+
|
|
106
|
+
/** Rough size of a tool block, so a large MCP toolset isn't mistaken for history. */
|
|
107
|
+
export function estimateToolTokens(tools = []) {
|
|
108
|
+
return tools.length ? Math.ceil(JSON.stringify(tools).length / 4) : 0;
|
|
109
|
+
}
|
|
110
|
+
|
|
111
|
+
/** Rough size of the wire payload, for when a provider reports no usage at all. */
|
|
112
|
+
export function estimateTokens(wireMessages) {
|
|
113
|
+
let chars = 0;
|
|
114
|
+
for (const m of wireMessages) {
|
|
115
|
+
if (typeof m.content === 'string') chars += m.content.length;
|
|
116
|
+
else if (Array.isArray(m.content)) {
|
|
117
|
+
for (const p of m.content) {
|
|
118
|
+
if (p.type === 'text') chars += p.text.length;
|
|
119
|
+
// Base64 length says nothing about what an image costs; providers bill
|
|
120
|
+
// a tile-based figure in the low thousands of tokens.
|
|
121
|
+
else if (p.type === 'image_url') chars += IMAGE_CHARS;
|
|
122
|
+
}
|
|
123
|
+
}
|
|
124
|
+
if (m.tool_calls) chars += JSON.stringify(m.tool_calls).length;
|
|
125
|
+
}
|
|
126
|
+
return Math.ceil(chars / 4);
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
/**
|
|
130
|
+
* Decides whether this turn should open a new epoch, and what to demote.
|
|
131
|
+
*
|
|
132
|
+
* Returns null when nothing should change -- which is the common case, and the
|
|
133
|
+
* case that keeps the prefix cached. Called once at the start of a turn, never
|
|
134
|
+
* mid-turn, so within a turn the prefix only ever grows.
|
|
135
|
+
*/
|
|
136
|
+
export function planEpoch(rows, { threshold, keepTurns, promptTokens }) {
|
|
137
|
+
if (!(promptTokens > threshold)) return null;
|
|
138
|
+
|
|
139
|
+
// Everything from the Nth-from-last user message onward stays verbatim: the
|
|
140
|
+
// model is most likely to still be working from the recent turns.
|
|
141
|
+
const userSeqs = rows.filter((r) => r.role === 'user').map((r) => r.seq);
|
|
142
|
+
if (userSeqs.length <= keepTurns) return null;
|
|
143
|
+
const boundarySeq = userSeqs[userSeqs.length - keepTurns];
|
|
144
|
+
|
|
145
|
+
// Old images go the same way as old tool output: they are resent in full on
|
|
146
|
+
// every turn, and unlike text nothing ever shrinks them otherwise.
|
|
147
|
+
const targets = rows.filter((r) => r.seq < boundarySeq && (
|
|
148
|
+
(r.role === 'tool' && r.artifact_id && !r.stub_text)
|
|
149
|
+
|| (r.images_json && !r.images_dropped)
|
|
150
|
+
));
|
|
151
|
+
// No candidates means bumping the epoch would cost a cache miss for nothing.
|
|
152
|
+
if (!targets.length) return null;
|
|
153
|
+
|
|
154
|
+
return { boundarySeq, targets };
|
|
155
|
+
}
|
|
156
|
+
|
|
157
|
+
/**
|
|
158
|
+
* Applies the plan: writes each stub back to the store and records the new
|
|
159
|
+
* boundary. After this returns, the prefix up to `boundarySeq` is frozen --
|
|
160
|
+
* stubs are persisted, so every later rebuild produces identical bytes.
|
|
161
|
+
*/
|
|
162
|
+
export function applyEpoch(store, chat, plan, { minSaved = 0 } = {}) {
|
|
163
|
+
// Stubs are worked out before anything is written: an epoch costs a full
|
|
164
|
+
// cache miss, so one that would only shave a few hundred characters is not
|
|
165
|
+
// worth opening, and the check has to happen before the store is touched.
|
|
166
|
+
const stubs = [];
|
|
167
|
+
let saved = 0;
|
|
168
|
+
const drops = [];
|
|
169
|
+
for (const row of plan.targets) {
|
|
170
|
+
if (row.role !== 'tool') {
|
|
171
|
+
drops.push(row.id);
|
|
172
|
+
saved += JSON.parse(row.images_json).length * IMAGE_CHARS;
|
|
173
|
+
continue;
|
|
174
|
+
}
|
|
175
|
+
const artifact = store.messages.getArtifact(row.artifact_id, chat.id);
|
|
176
|
+
if (!artifact) continue;
|
|
177
|
+
const stub = digest(artifact);
|
|
178
|
+
if (stub.length >= (row.content?.length ?? 0)) continue;
|
|
179
|
+
stubs.push([row.id, stub]);
|
|
180
|
+
saved += (row.content?.length ?? 0) - stub.length;
|
|
181
|
+
}
|
|
182
|
+
if (!saved || saved < minSaved) return null;
|
|
183
|
+
for (const [id, stub] of stubs) store.messages.setStub(id, stub);
|
|
184
|
+
for (const id of drops) store.messages.dropImages(id);
|
|
185
|
+
const epoch = chat.epoch + 1;
|
|
186
|
+
store.chats.touch(chat.id, { epoch, boundary_seq: plan.boundarySeq });
|
|
187
|
+
return { epoch, boundarySeq: plan.boundarySeq, saved };
|
|
188
|
+
}
|
|
189
|
+
|
|
190
|
+
/**
|
|
191
|
+
* The hard window: what happens when stubbing is not enough.
|
|
192
|
+
*
|
|
193
|
+
* Compaction only ever shrinks tool output and images, so a long conversation
|
|
194
|
+
* of plain text has nothing to give back and would grow until the provider
|
|
195
|
+
* refuses it outright. Past `maxTokens` the oldest turns leave the wire
|
|
196
|
+
* entirely. The cut always lands on a user message, so no tool call is ever
|
|
197
|
+
* separated from its result, and it moves back far enough to bring the history
|
|
198
|
+
* down to `targetTokens` -- a big step taken rarely, since every move is a full
|
|
199
|
+
* cache miss, the same trade an epoch makes. The last `keepTurns` turns are
|
|
200
|
+
* never cut, whatever they cost.
|
|
201
|
+
*
|
|
202
|
+
* Returns the new first visible seq, or null to leave the window where it is.
|
|
203
|
+
* `rows` must already be what the model would see (window applied, stubs in).
|
|
204
|
+
*/
|
|
205
|
+
export function planWindow(rows, { maxTokens, targetTokens, keepTurns, toWire }) {
|
|
206
|
+
if (!(maxTokens > 0)) return null;
|
|
207
|
+
const sizes = rows.map((r) => estimateTokens([toWire(r)]));
|
|
208
|
+
let total = sizes.reduce((a, b) => a + b, 0);
|
|
209
|
+
if (total <= maxTokens) return null;
|
|
210
|
+
|
|
211
|
+
const userIdx = rows.map((r, i) => (r.role === 'user' ? i : -1)).filter((i) => i >= 0);
|
|
212
|
+
if (userIdx.length <= keepTurns) return null;
|
|
213
|
+
const lastCut = userIdx[userIdx.length - keepTurns];
|
|
214
|
+
|
|
215
|
+
let cut = 0;
|
|
216
|
+
for (const i of userIdx) {
|
|
217
|
+
if (i === 0) continue;
|
|
218
|
+
if (i > lastCut) break;
|
|
219
|
+
for (let k = cut; k < i; k++) total -= sizes[k];
|
|
220
|
+
cut = i;
|
|
221
|
+
if (total <= targetTokens) break;
|
|
222
|
+
}
|
|
223
|
+
return cut > 0 ? rows[cut].seq : null;
|
|
224
|
+
}
|
|
225
|
+
|
|
226
|
+
/**
|
|
227
|
+
* Applies a window to stored rows: drops what is before it and marks the
|
|
228
|
+
* first remaining message so the model knows the conversation did not start
|
|
229
|
+
* there. Pure and deterministic, so every rebuild sends the same bytes.
|
|
230
|
+
*/
|
|
231
|
+
export function windowRows(rows, windowSeq, { summarized = false } = {}) {
|
|
232
|
+
if (!(windowSeq >= 0)) return rows;
|
|
233
|
+
const kept = rows.filter((r) => r.seq >= windowSeq);
|
|
234
|
+
const dropped = rows.length - kept.length;
|
|
235
|
+
if (!dropped || !kept.length || kept[0].role !== 'user') return kept;
|
|
236
|
+
const note = `[Earlier conversation omitted: ${dropped} message${dropped === 1 ? '' : 's'} before this`
|
|
237
|
+
+ ' point were removed to keep the conversation within the context window. '
|
|
238
|
+
+ (summarized
|
|
239
|
+
? 'They are summarised in the system prompt; if something you need is missing there, ask the user.]'
|
|
240
|
+
: 'If something from before is needed and is not restated here, ask the user.]');
|
|
241
|
+
return [{ ...kept[0], content: `${note}\n\n${kept[0].content ?? ''}` }, ...kept.slice(1)];
|
|
242
|
+
}
|