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,183 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* expand_context -- the restore half of compaction.
|
|
3
|
+
*
|
|
4
|
+
* Dropping content from the window is only safe if something in what remains
|
|
5
|
+
* can bring it back. Every compacted tool result keeps its artifact id, and
|
|
6
|
+
* this tool reads the stored text by that id.
|
|
7
|
+
*
|
|
8
|
+
* It is deliberately built on grep-and-window semantics rather than on semantic
|
|
9
|
+
* search: the artifact is an opaque blob from an arbitrary MCP server, so there
|
|
10
|
+
* is no schema to rank against and no safe assumption about what "relevant"
|
|
11
|
+
* means. Line matching and byte offsets work on anything, are deterministic,
|
|
12
|
+
* and cost nothing to run. Expansions are appended like any other tool result,
|
|
13
|
+
* so they never disturb the cached prefix.
|
|
14
|
+
*/
|
|
15
|
+
|
|
16
|
+
export const EXPAND_CONTEXT = 'expand_context';
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* Where the substantial prose actually sits.
|
|
20
|
+
*
|
|
21
|
+
* A scraped page is mostly navigation, bylines and short boilerplate; the
|
|
22
|
+
* article itself is the handful of long lines among them. A stub that keeps
|
|
23
|
+
* only the ends leaves the model guessing at `offset=` values, which is the
|
|
24
|
+
* expensive failure -- each blind guess costs a whole round and comes back with
|
|
25
|
+
* sidebar links. Listing where the long lines start turns that into one aimed
|
|
26
|
+
* read.
|
|
27
|
+
*
|
|
28
|
+
* Character offsets, not line numbers, because that is what expand_context's
|
|
29
|
+
* `offset` takes. Generic and deterministic: it measures line lengths and
|
|
30
|
+
* nothing else, and never looks at what a tool is.
|
|
31
|
+
*/
|
|
32
|
+
export const MAP_MIN_LINE = 200;
|
|
33
|
+
export const MAP_MAX_ENTRIES = 12;
|
|
34
|
+
|
|
35
|
+
export function textMap(text) {
|
|
36
|
+
const rows = [];
|
|
37
|
+
let pos = 0;
|
|
38
|
+
for (const line of String(text ?? '').split('\n')) {
|
|
39
|
+
const len = line.trim().length;
|
|
40
|
+
if (len >= MAP_MIN_LINE) rows.push({ pos, len });
|
|
41
|
+
pos += line.length + 1;
|
|
42
|
+
}
|
|
43
|
+
if (!rows.length) return '';
|
|
44
|
+
|
|
45
|
+
// Longest first so the densest prose survives the cut, then back into reading
|
|
46
|
+
// order -- the model pages forward, so the list has to read forward too.
|
|
47
|
+
const top = rows.slice().sort((a, b) => b.len - a.len || a.pos - b.pos).slice(0, MAP_MAX_ENTRIES);
|
|
48
|
+
top.sort((a, b) => a.pos - b.pos);
|
|
49
|
+
const omitted = rows.length - top.length;
|
|
50
|
+
|
|
51
|
+
return `Long text at offset= ${top.map((r) => `${r.pos} (${r.len} chars)`).join(', ')}`
|
|
52
|
+
+ (omitted > 0 ? `, +${omitted} more` : '');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function expandToolDef() {
|
|
56
|
+
return {
|
|
57
|
+
type: 'function',
|
|
58
|
+
function: {
|
|
59
|
+
name: EXPAND_CONTEXT,
|
|
60
|
+
description: [
|
|
61
|
+
'Read the full text of an earlier tool result that was compacted out of the',
|
|
62
|
+
'conversation. Compacted results appear as "[compacted: artifact <id> ...]" and',
|
|
63
|
+
'show only their first and last few hundred characters.',
|
|
64
|
+
'',
|
|
65
|
+
'Use grep to find the part you need (case-insensitive regular expression, matched',
|
|
66
|
+
'per line), or offset/limit to page through the text. With neither, returns the',
|
|
67
|
+
'beginning. Output is capped, so prefer a specific grep over paging.'
|
|
68
|
+
].join('\n'),
|
|
69
|
+
parameters: {
|
|
70
|
+
type: 'object',
|
|
71
|
+
properties: {
|
|
72
|
+
artifact_id: {
|
|
73
|
+
type: 'string',
|
|
74
|
+
description: 'The id shown in the compacted marker, e.g. "a1b2c3d4".'
|
|
75
|
+
},
|
|
76
|
+
grep: {
|
|
77
|
+
type: 'string',
|
|
78
|
+
description:
|
|
79
|
+
'Case-insensitive regular expression. Returns matching lines with surrounding context.'
|
|
80
|
+
},
|
|
81
|
+
context_lines: {
|
|
82
|
+
type: 'integer',
|
|
83
|
+
description: 'Lines of context to show around each grep match. Default 2.'
|
|
84
|
+
},
|
|
85
|
+
offset: {
|
|
86
|
+
type: 'integer',
|
|
87
|
+
description: 'Character offset to start reading from. Ignored when grep is given.'
|
|
88
|
+
},
|
|
89
|
+
limit: {
|
|
90
|
+
type: 'integer',
|
|
91
|
+
description: 'Maximum characters to return. Capped by the server budget.'
|
|
92
|
+
}
|
|
93
|
+
},
|
|
94
|
+
required: ['artifact_id']
|
|
95
|
+
}
|
|
96
|
+
}
|
|
97
|
+
};
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function header(artifact, note) {
|
|
101
|
+
return `[artifact ${artifact.id} · ${artifact.tool_name} · ${artifact.char_len.toLocaleString('en-US')} chars · ${note}]`;
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function grepView(text, pattern, contextLines, budget, artifact) {
|
|
105
|
+
let re;
|
|
106
|
+
try {
|
|
107
|
+
re = new RegExp(pattern, 'i');
|
|
108
|
+
} catch (err) {
|
|
109
|
+
return `Error: invalid regular expression ${JSON.stringify(pattern)}: ${err.message}`;
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
const lines = text.split('\n');
|
|
113
|
+
const hits = [];
|
|
114
|
+
for (let i = 0; i < lines.length; i++) if (re.test(lines[i])) hits.push(i);
|
|
115
|
+
if (!hits.length) {
|
|
116
|
+
// A bare "no match" costs a full round and returns nothing to act on, so the
|
|
117
|
+
// miss carries the same offset map the stub does: the next call can aim at
|
|
118
|
+
// real text instead of guessing another pattern.
|
|
119
|
+
const map = textMap(text);
|
|
120
|
+
return [
|
|
121
|
+
header(artifact, `no match for /${pattern}/i`),
|
|
122
|
+
map || 'No long text blocks -- this artifact is short lines throughout.',
|
|
123
|
+
'Try a broader pattern, or read a window with offset/limit.'
|
|
124
|
+
].join('\n');
|
|
125
|
+
}
|
|
126
|
+
|
|
127
|
+
// Merge overlapping context windows so a dense run of matches prints once.
|
|
128
|
+
const ranges = [];
|
|
129
|
+
for (const i of hits) {
|
|
130
|
+
const from = Math.max(0, i - contextLines);
|
|
131
|
+
const to = Math.min(lines.length - 1, i + contextLines);
|
|
132
|
+
const last = ranges[ranges.length - 1];
|
|
133
|
+
if (last && from <= last.to + 1) last.to = Math.max(last.to, to);
|
|
134
|
+
else ranges.push({ from, to });
|
|
135
|
+
}
|
|
136
|
+
|
|
137
|
+
const out = [header(artifact, `${hits.length} line(s) match /${pattern}/i`)];
|
|
138
|
+
let used = out[0].length;
|
|
139
|
+
let shown = 0;
|
|
140
|
+
for (const r of ranges) {
|
|
141
|
+
const block = lines
|
|
142
|
+
.slice(r.from, r.to + 1)
|
|
143
|
+
.map((l, k) => `${String(r.from + k + 1).padStart(6)}│${l}`)
|
|
144
|
+
.join('\n');
|
|
145
|
+
if (used + block.length > budget) {
|
|
146
|
+
out.push(`… ${ranges.length - shown} more match group(s) omitted; narrow the pattern.`);
|
|
147
|
+
break;
|
|
148
|
+
}
|
|
149
|
+
out.push(block);
|
|
150
|
+
used += block.length + 2;
|
|
151
|
+
shown++;
|
|
152
|
+
}
|
|
153
|
+
return out.join('\n\n');
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function windowView(text, offset, limit, budget, artifact) {
|
|
157
|
+
const start = Math.max(0, Math.min(offset, text.length));
|
|
158
|
+
const size = Math.min(limit > 0 ? limit : budget, budget);
|
|
159
|
+
const slice = text.slice(start, start + size);
|
|
160
|
+
const end = start + slice.length;
|
|
161
|
+
const note = `chars ${start.toLocaleString('en-US')}–${end.toLocaleString('en-US')}`;
|
|
162
|
+
const more = end < text.length
|
|
163
|
+
? `\n[… ${(text.length - end).toLocaleString('en-US')} chars remain; continue with offset=${end}.]`
|
|
164
|
+
: '';
|
|
165
|
+
return `${header(artifact, note)}\n${slice}${more}`;
|
|
166
|
+
}
|
|
167
|
+
|
|
168
|
+
/** Runs one expansion. Returns a string, like every other tool result. */
|
|
169
|
+
export function callExpand(args, { store, chatId, budget = 8000 }) {
|
|
170
|
+
const id = String(args?.artifact_id || '').trim();
|
|
171
|
+
if (!id) return 'Error: artifact_id is required.';
|
|
172
|
+
|
|
173
|
+
// Scoped to the conversation that produced it at the query level: artifact ids
|
|
174
|
+
// are short, and one chat must not be able to read another's tool output by guessing.
|
|
175
|
+
const artifact = store.messages.getArtifact(id, chatId);
|
|
176
|
+
if (!artifact) return `Error: no artifact "${id}". Ids appear in the "[compacted: artifact <id> ...]" marker.`;
|
|
177
|
+
|
|
178
|
+
if (args.grep) {
|
|
179
|
+
const ctx = Number.isInteger(args.context_lines) ? Math.max(0, Math.min(args.context_lines, 20)) : 2;
|
|
180
|
+
return grepView(artifact.content, String(args.grep), ctx, budget, artifact);
|
|
181
|
+
}
|
|
182
|
+
return windowView(artifact.content, Number(args.offset) || 0, Number(args.limit) || 0, budget, artifact);
|
|
183
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import { ALL_USERS, CORPUS } from '../store/index.js';
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* read_document -- lets the model read text the user attached to the chat.
|
|
5
|
+
*
|
|
6
|
+
* Same shape as expand_context: a document is an opaque blob (extracted text
|
|
7
|
+
* of whatever the user dropped in), scoped to the chat that owns it, read
|
|
8
|
+
* either by a narrowing query or by paging through raw offsets. `query`
|
|
9
|
+
* ranks the document's pre-split passages: FTS5 bm25() by default, or
|
|
10
|
+
* small-to-big dense / hybrid retrieval when retrieval.mode asks for it
|
|
11
|
+
* (retrieval.js). The tool's interface is the same in every mode.
|
|
12
|
+
*/
|
|
13
|
+
|
|
14
|
+
export const READ_DOCUMENT = 'read_document';
|
|
15
|
+
|
|
16
|
+
export function documentToolDef() {
|
|
17
|
+
return {
|
|
18
|
+
type: 'function',
|
|
19
|
+
function: {
|
|
20
|
+
name: READ_DOCUMENT,
|
|
21
|
+
description: [
|
|
22
|
+
'Read an attached document. Documents appear as',
|
|
23
|
+
'"[Attached document: ... (id: <id>, ...)]" notes in the conversation.',
|
|
24
|
+
'',
|
|
25
|
+
'Give a query to search the document for relevant passages (best for',
|
|
26
|
+
'long documents and specific questions), or use offset/limit to page through the',
|
|
27
|
+
'raw text from the start. With neither, returns the beginning of the document.'
|
|
28
|
+
].join('\n'),
|
|
29
|
+
parameters: {
|
|
30
|
+
type: 'object',
|
|
31
|
+
properties: {
|
|
32
|
+
document_id: {
|
|
33
|
+
type: 'string',
|
|
34
|
+
description: 'The id shown in the "[Attached document: ...]" note, e.g. "a1b2c3d4".'
|
|
35
|
+
},
|
|
36
|
+
query: {
|
|
37
|
+
type: 'string',
|
|
38
|
+
description: 'Free-text query. Returns the best-matching passages, ranked by relevance.'
|
|
39
|
+
},
|
|
40
|
+
offset: {
|
|
41
|
+
type: 'integer',
|
|
42
|
+
description: 'Character offset to start reading from. Ignored when query is given.'
|
|
43
|
+
},
|
|
44
|
+
limit: {
|
|
45
|
+
type: 'integer',
|
|
46
|
+
description: 'Maximum characters to return. Capped by the server budget.'
|
|
47
|
+
}
|
|
48
|
+
},
|
|
49
|
+
required: ['document_id']
|
|
50
|
+
}
|
|
51
|
+
}
|
|
52
|
+
};
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function header(doc, note) {
|
|
56
|
+
return `[document ${doc.id} · ${doc.filename} · ${doc.char_len.toLocaleString('en-US')} chars · ${note}]`;
|
|
57
|
+
}
|
|
58
|
+
|
|
59
|
+
async function queryView(store, retrieval, doc, query, budget) {
|
|
60
|
+
const hits = retrieval ? await retrieval.search(CORPUS.DOCUMENTS, { docId: doc.id }, query, 5) : store.documents.searchPassages(doc.id, query, 5);
|
|
61
|
+
if (!hits.length) {
|
|
62
|
+
return `${header(doc, `no match for "${query}"`)}\nTry a broader query, or read from the start with offset/limit.`;
|
|
63
|
+
}
|
|
64
|
+
const out = [header(doc, `${hits.length} passage(s) matching "${query}"`)];
|
|
65
|
+
let used = out[0].length;
|
|
66
|
+
for (const hit of hits) {
|
|
67
|
+
const block = `--- passage ${hit.passageIdx} (offset ${hit.charStart.toLocaleString('en-US')}) ---\n${hit.body}`;
|
|
68
|
+
if (used + block.length > budget) {
|
|
69
|
+
out.push('… remaining matches omitted; narrow the query.');
|
|
70
|
+
break;
|
|
71
|
+
}
|
|
72
|
+
out.push(block);
|
|
73
|
+
used += block.length + 2;
|
|
74
|
+
}
|
|
75
|
+
return out.join('\n\n');
|
|
76
|
+
}
|
|
77
|
+
|
|
78
|
+
function windowView(doc, offset, limit, budget) {
|
|
79
|
+
const text = doc.content;
|
|
80
|
+
const start = Math.max(0, Math.min(offset, text.length));
|
|
81
|
+
const size = Math.min(limit > 0 ? limit : budget, budget);
|
|
82
|
+
const slice = text.slice(start, start + size);
|
|
83
|
+
const end = start + slice.length;
|
|
84
|
+
const note = `chars ${start.toLocaleString('en-US')}–${end.toLocaleString('en-US')}`;
|
|
85
|
+
const more = end < text.length
|
|
86
|
+
? `\n[… ${(text.length - end).toLocaleString('en-US')} chars remain; continue with offset=${end}.]`
|
|
87
|
+
: '';
|
|
88
|
+
return `${header(doc, note)}\n${slice}${more}`;
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
/** Runs one read. Returns a string, like every other tool result. */
|
|
92
|
+
export async function callReadDocument(args, { store, retrieval = null, chatId, budget = 8000 }) {
|
|
93
|
+
const id = String(args?.document_id || '').trim();
|
|
94
|
+
if (!id) return 'Error: document_id is required.';
|
|
95
|
+
|
|
96
|
+
const doc = store.documents.get(id, ALL_USERS);
|
|
97
|
+
if (!doc) return `Error: no document "${id}". Ids appear in the "[Attached document: ...]" note.`;
|
|
98
|
+
if (doc.chat_id !== chatId) return `Error: document "${id}" does not belong to this conversation.`;
|
|
99
|
+
|
|
100
|
+
if (args.query) return queryView(store, retrieval, doc, String(args.query), budget);
|
|
101
|
+
return windowView(doc, Number(args.offset) || 0, Number(args.limit) || 0, budget);
|
|
102
|
+
}
|
|
@@ -0,0 +1,63 @@
|
|
|
1
|
+
/** A small, chat-scoped checklist that the model can maintain during a task. */
|
|
2
|
+
export const MANAGE_TASKS = 'manage_tasks';
|
|
3
|
+
|
|
4
|
+
export function taskToolDef() {
|
|
5
|
+
return {
|
|
6
|
+
type: 'function',
|
|
7
|
+
function: {
|
|
8
|
+
name: MANAGE_TASKS,
|
|
9
|
+
description: 'Keep a visible checklist for this chat. Create tasks when a request has multiple steps. Mark a task in_progress when you start it and completed as soon as it is done; before your final answer, make sure no finished task is still open. Update several tasks at once with ids. This is a checklist, not a scheduled automation.',
|
|
10
|
+
parameters: {
|
|
11
|
+
type: 'object',
|
|
12
|
+
properties: {
|
|
13
|
+
action: { type: 'string', enum: ['add', 'update', 'list'] },
|
|
14
|
+
title: { type: 'string', description: 'Short task title for add.' },
|
|
15
|
+
id: { type: 'string', description: 'Task ID returned by add or list, for update.' },
|
|
16
|
+
ids: { type: 'array', items: { type: 'string' }, description: 'Several task IDs to set to the same status, for update.' },
|
|
17
|
+
status: { type: 'string', enum: ['pending', 'in_progress', 'completed'], description: 'New status for update.' }
|
|
18
|
+
},
|
|
19
|
+
required: ['action']
|
|
20
|
+
}
|
|
21
|
+
}
|
|
22
|
+
};
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function callManageTasks(args, { store, chatId, onChange } = {}) {
|
|
26
|
+
if (!store || !chatId) return 'Error: this tool requires a chat.';
|
|
27
|
+
const { action } = args || {};
|
|
28
|
+
if (action === 'list') return JSON.stringify({ tasks: store.chats.listTasks(chatId) });
|
|
29
|
+
if (action === 'add') {
|
|
30
|
+
const title = typeof args.title === 'string' ? args.title.trim() : '';
|
|
31
|
+
if (!title || title.length > 200) return 'Error: title must be 1–200 characters.';
|
|
32
|
+
if (store.chats.listTasks(chatId).length >= 50) return 'Error: this chat has reached the 50-task limit.';
|
|
33
|
+
const task = store.chats.addTask(chatId, title);
|
|
34
|
+
onChange?.(store.chats.listTasks(chatId));
|
|
35
|
+
return JSON.stringify({ task });
|
|
36
|
+
}
|
|
37
|
+
if (action === 'update') {
|
|
38
|
+
const ids = Array.isArray(args.ids) ? args.ids : [args.id];
|
|
39
|
+
if (!ids.length || ids.some((id) => typeof id !== 'string' || !id)
|
|
40
|
+
|| !['pending', 'in_progress', 'completed'].includes(args.status)) {
|
|
41
|
+
return 'Error: update requires a valid task id (or ids) and status.';
|
|
42
|
+
}
|
|
43
|
+
const tasks = ids.map((id) => store.chats.updateTask(chatId, id, args.status));
|
|
44
|
+
const missing = ids.filter((_, i) => !tasks[i]);
|
|
45
|
+
if (missing.length === ids.length) return 'Error: task not found in this chat.';
|
|
46
|
+
onChange?.(store.chats.listTasks(chatId));
|
|
47
|
+
if (!Array.isArray(args.ids)) return JSON.stringify({ task: tasks[0] });
|
|
48
|
+
return JSON.stringify({ tasks: tasks.filter(Boolean), ...(missing.length ? { not_found: missing } : {}) });
|
|
49
|
+
}
|
|
50
|
+
return 'Error: action must be add, update, or list.';
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
/**
|
|
54
|
+
* The open checklist, for the budget footer. The model otherwise sees tasks
|
|
55
|
+
* only in the tool results that created them, which scroll out of attention
|
|
56
|
+
* within a turn and are gone from view entirely on the next one.
|
|
57
|
+
*/
|
|
58
|
+
export function openTasksNote(tasks) {
|
|
59
|
+
const open = (tasks || []).filter((t) => t.status !== 'completed');
|
|
60
|
+
if (!open.length) return '';
|
|
61
|
+
const lines = open.map((t) => `- ${t.id} [${t.status}] ${t.title}`);
|
|
62
|
+
return `\n[Open tasks -- update them with ${MANAGE_TASKS} as you finish each one:\n${lines.join('\n')}]`;
|
|
63
|
+
}
|