tinywebui 0.1.4 → 0.1.5

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.
Files changed (82) hide show
  1. package/README.md +20 -13
  2. package/bin/tinywebui.js +69 -53
  3. package/package.json +25 -8
  4. package/public/app.js +40 -34
  5. package/public/chat/composer.js +1 -1
  6. package/public/chat/sidebar.js +8 -5
  7. package/public/index.html +88 -53
  8. package/public/panels/admin.js +40 -1
  9. package/public/panels/model-editor.js +336 -0
  10. package/public/panels/panels.js +37 -12
  11. package/public/panels/settings-home.js +125 -0
  12. package/public/panels/settings.js +124 -36
  13. package/public/shared/attachment_note.js +7 -1
  14. package/public/shared/events.js +1 -0
  15. package/public/shared/features.js +1 -0
  16. package/public/shared/origins.js +1 -0
  17. package/public/shared/usage.js +16 -1
  18. package/public/styles.css +84 -21
  19. package/src/access/auth.js +155 -155
  20. package/src/access/auth_gate.js +39 -38
  21. package/src/access/email.js +17 -0
  22. package/src/access/policy.js +123 -136
  23. package/src/audit.js +1 -1
  24. package/src/automations/automation.js +135 -123
  25. package/src/automations/scheduler.js +138 -127
  26. package/src/chat/compact.js +149 -156
  27. package/src/chat/llm.js +380 -382
  28. package/src/chat/runs.js +162 -151
  29. package/src/chat/tool_executor.js +229 -218
  30. package/src/cli.js +535 -433
  31. package/src/config/approval.js +35 -29
  32. package/src/config/config.js +546 -454
  33. package/src/config/data_dir.js +24 -0
  34. package/src/config/models.js +191 -115
  35. package/src/config/refs.js +32 -0
  36. package/src/config/secrets.js +153 -0
  37. package/src/files/documents.js +57 -68
  38. package/src/files/images.js +30 -31
  39. package/src/harness/agent.js +60 -60
  40. package/src/harness/attribution.js +66 -58
  41. package/src/harness/checkpoint.js +67 -62
  42. package/src/harness/history.js +28 -23
  43. package/src/harness/prompt.js +40 -57
  44. package/src/harness/provider.js +284 -283
  45. package/src/harness/types.js +1 -0
  46. package/src/http.js +90 -72
  47. package/src/log.js +11 -12
  48. package/src/mcp.js +398 -374
  49. package/src/retrieval/embedding.js +228 -240
  50. package/src/retrieval/retrieval.js +182 -173
  51. package/src/routes/admin.js +178 -138
  52. package/src/routes/auth.js +184 -111
  53. package/src/routes/automations.js +99 -82
  54. package/src/routes/chats.js +240 -218
  55. package/src/routes/documents.js +130 -120
  56. package/src/routes/library.js +43 -46
  57. package/src/routes/mcp.js +49 -49
  58. package/src/routes/settings.js +127 -122
  59. package/src/routes/types.js +1 -0
  60. package/src/server.js +426 -415
  61. package/src/store/automations.js +106 -106
  62. package/src/store/chats.js +217 -242
  63. package/src/store/documents.js +181 -185
  64. package/src/store/embeddings.js +52 -54
  65. package/src/store/index.js +95 -49
  66. package/src/store/messages.js +55 -80
  67. package/src/store/schema.js +128 -107
  68. package/src/store/scope.js +18 -17
  69. package/src/store/search.js +25 -27
  70. package/src/store/turns.js +53 -53
  71. package/src/store/usage.js +343 -269
  72. package/src/store/users.js +162 -87
  73. package/src/store/wire.js +50 -41
  74. package/src/tools/ask_tool.js +68 -68
  75. package/src/tools/chat_search_tool.js +38 -44
  76. package/src/tools/context_tool.js +131 -138
  77. package/src/tools/document_tool.js +73 -78
  78. package/src/tools/mcp_tool.js +274 -232
  79. package/src/tools/task_tool.js +53 -50
  80. package/src/tools/types.js +1 -0
  81. package/public/panels/connectors.js +0 -184
  82. package/src/set-password.js +0 -43
@@ -1,5 +1,4 @@
1
1
  import { CORPUS } from '../store/index.js';
2
-
3
2
  /**
4
3
  * search_chats -- lets the model look up the user's earlier conversations:
5
4
  * what they asked and the answer they finally got. Ranked by the same
@@ -8,56 +7,51 @@ import { CORPUS } from '../store/index.js';
8
7
  * chats are searched, and never the asking chat itself -- that one is already
9
8
  * in context (and expand_context reads what compaction folded away).
10
9
  */
11
-
12
10
  export const SEARCH_CHATS = 'search_chats';
13
-
14
11
  const MAX_RESULTS = 10;
15
-
16
12
  export function chatSearchToolDef() {
17
- return {
18
- type: 'function',
19
- function: {
20
- name: SEARCH_CHATS,
21
- description: [
22
- "Search the user's earlier conversations (not this one) for what they asked and the answers they got.",
23
- 'Use it when the user refers to something discussed before ("like last time", "what did we decide about..."),',
24
- 'or when an earlier answer would save redoing work. Returns the best-matching question/answer pairs,',
25
- 'each with the chat it came from. Phrase the query as the topic, not as a question to the user.'
26
- ].join(' '),
27
- parameters: {
28
- type: 'object',
29
- properties: {
30
- query: { type: 'string', description: 'What to look for, in plain words.' },
31
- limit: { type: 'integer', description: `How many turns to return, 1-${MAX_RESULTS} (default 5).` }
32
- },
33
- required: ['query']
34
- }
35
- }
36
- };
13
+ return {
14
+ type: 'function',
15
+ function: {
16
+ name: SEARCH_CHATS,
17
+ description: [
18
+ "Search the user's earlier conversations (not this one) for what they asked and the answers they got.",
19
+ 'Use it when the user refers to something discussed before ("like last time", "what did we decide about..."),',
20
+ 'or when an earlier answer would save redoing work. Returns the best-matching question/answer pairs,',
21
+ 'each with the chat it came from. Phrase the query as the topic, not as a question to the user.'
22
+ ].join(' '),
23
+ parameters: {
24
+ type: 'object',
25
+ properties: {
26
+ query: { type: 'string', description: 'What to look for, in plain words.' },
27
+ limit: { type: 'integer', description: `How many turns to return, 1-${MAX_RESULTS} (default 5).` }
28
+ },
29
+ required: ['query']
30
+ }
31
+ }
32
+ };
37
33
  }
38
-
39
34
  const day = (ms) => (ms ? new Date(ms).toISOString().slice(0, 10) : 'undated');
40
-
41
35
  function clip(text, max) {
42
- const t = String(text || '').trim();
43
- return t.length > max ? `${t.slice(0, max)}… [${(t.length - max).toLocaleString('en-US')} more chars]` : t;
36
+ const t = String(text || '').trim();
37
+ return t.length > max ? `${t.slice(0, max)}… [${(t.length - max).toLocaleString('en-US')} more chars]` : t;
44
38
  }
45
-
46
39
  /** Runs one search. Returns a string, like every other tool result. */
47
40
  export async function callSearchChats(args, { store, retrieval, chatId, budget = 8000 }) {
48
- const query = String(args?.query || '').trim();
49
- if (!query) return 'Error: query is required.';
50
- const limit = Math.min(MAX_RESULTS, Math.max(1, Number.parseInt(args?.limit, 10) || 5));
51
- const owner = chatId ? store.chats.byId(chatId)?.user_id ?? null : null;
52
- const hits = await retrieval.search(CORPUS.CHATS, { userId: owner, excludeChatId: chatId ?? null }, query, limit);
53
- if (!hits.length) return `[search_chats · no earlier conversation matches "${query}"]\nTry other words for the same topic.`;
54
-
55
- const out = [`[search_chats · ${hits.length} turn(s) matching "${query}"]`];
56
- const share = Math.max(400, Math.floor((budget - out[0].length) / hits.length) - 120);
57
- for (const h of hits) {
58
- const q = clip(h.question, Math.min(600, Math.floor(share / 3)));
59
- const a = h.answer ? clip(h.answer, share - q.length) : '(no answer recorded)';
60
- out.push(`--- "${h.chatTitle}" · ${day(h.createdAt)} · chat ${h.chatId} ---\nQ: ${q}\nA: ${a}`);
61
- }
62
- return out.join('\n\n');
41
+ const query = String(args?.query || '').trim();
42
+ if (!query)
43
+ return 'Error: query is required.';
44
+ const limit = Math.min(MAX_RESULTS, Math.max(1, Number.parseInt(args?.limit, 10) || 5));
45
+ const owner = chatId ? store.chats.byId(chatId)?.user_id ?? null : null;
46
+ const hits = await retrieval.search(CORPUS.CHATS, { userId: owner, excludeChatId: chatId ?? null }, query, limit);
47
+ if (!hits.length)
48
+ return `[search_chats · no earlier conversation matches "${query}"]\nTry other words for the same topic.`;
49
+ const out = [`[search_chats · ${hits.length} turn(s) matching "${query}"]`];
50
+ const share = Math.max(400, Math.floor((budget - out[0].length) / hits.length) - 120);
51
+ for (const h of hits) {
52
+ const q = clip(h.question, Math.min(600, Math.floor(share / 3)));
53
+ const a = h.answer ? clip(h.answer, share - q.length) : '(no answer recorded)';
54
+ out.push(`--- "${h.chatTitle}" · ${day(h.createdAt)} · chat ${h.chatId} ---\nQ: ${q}\nA: ${a}`);
55
+ }
56
+ return out.join('\n\n');
63
57
  }
@@ -12,9 +12,7 @@
12
12
  * and cost nothing to run. Expansions are appended like any other tool result,
13
13
  * so they never disturb the cached prefix.
14
14
  */
15
-
16
15
  export const EXPAND_CONTEXT = 'expand_context';
17
-
18
16
  /**
19
17
  * Where the substantial prose actually sits.
20
18
  *
@@ -31,153 +29,148 @@ export const EXPAND_CONTEXT = 'expand_context';
31
29
  */
32
30
  export const MAP_MIN_LINE = 200;
33
31
  export const MAP_MAX_ENTRIES = 12;
34
-
35
32
  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` : '');
33
+ const rows = [];
34
+ let pos = 0;
35
+ for (const line of String(text ?? '').split('\n')) {
36
+ const len = line.trim().length;
37
+ if (len >= MAP_MIN_LINE)
38
+ rows.push({ pos, len });
39
+ pos += line.length + 1;
40
+ }
41
+ if (!rows.length)
42
+ return '';
43
+ // Longest first so the densest prose survives the cut, then back into reading
44
+ // order -- the model pages forward, so the list has to read forward too.
45
+ const top = rows.slice().sort((a, b) => b.len - a.len || a.pos - b.pos).slice(0, MAP_MAX_ENTRIES);
46
+ top.sort((a, b) => a.pos - b.pos);
47
+ const omitted = rows.length - top.length;
48
+ return `Long text at offset= ${top.map((r) => `${r.pos} (${r.len} chars)`).join(', ')}`
49
+ + (omitted > 0 ? `, +${omitted} more` : '');
53
50
  }
54
-
55
51
  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
- };
52
+ return {
53
+ type: 'function',
54
+ function: {
55
+ name: EXPAND_CONTEXT,
56
+ description: [
57
+ 'Read the full text of an earlier tool result that was compacted out of the',
58
+ 'conversation. Compacted results appear as "[compacted: artifact <id> ...]" and',
59
+ 'show only their first and last few hundred characters.',
60
+ '',
61
+ 'Use grep to find the part you need (case-insensitive regular expression, matched',
62
+ 'per line), or offset/limit to page through the text. With neither, returns the',
63
+ 'beginning. Output is capped, so prefer a specific grep over paging.'
64
+ ].join('\n'),
65
+ parameters: {
66
+ type: 'object',
67
+ properties: {
68
+ artifact_id: {
69
+ type: 'string',
70
+ description: 'The id shown in the compacted marker, e.g. "a1b2c3d4".'
71
+ },
72
+ grep: {
73
+ type: 'string',
74
+ description: 'Case-insensitive regular expression. Returns matching lines with surrounding context.'
75
+ },
76
+ context_lines: {
77
+ type: 'integer',
78
+ description: 'Lines of context to show around each grep match. Default 2.'
79
+ },
80
+ offset: {
81
+ type: 'integer',
82
+ description: 'Character offset to start reading from. Ignored when grep is given.'
83
+ },
84
+ limit: {
85
+ type: 'integer',
86
+ description: 'Maximum characters to return. Capped by the server budget.'
87
+ }
88
+ },
89
+ required: ['artifact_id']
90
+ }
91
+ }
92
+ };
98
93
  }
99
-
100
94
  function header(artifact, note) {
101
- return `[artifact ${artifact.id} · ${artifact.tool_name} · ${artifact.char_len.toLocaleString('en-US')} chars · ${note}]`;
95
+ return `[artifact ${artifact.id} · ${artifact.tool_name} · ${artifact.char_len.toLocaleString('en-US')} chars · ${note}]`;
102
96
  }
103
-
104
97
  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;
98
+ let re;
99
+ try {
100
+ re = new RegExp(pattern, 'i');
101
+ }
102
+ catch (err) {
103
+ return `Error: invalid regular expression ${JSON.stringify(pattern)}: ${err.message}`;
104
+ }
105
+ const lines = text.split('\n');
106
+ const hits = [];
107
+ for (let i = 0; i < lines.length; i++)
108
+ if (re.test(lines[i]))
109
+ hits.push(i);
110
+ if (!hits.length) {
111
+ // A bare "no match" costs a full round and returns nothing to act on, so the
112
+ // miss carries the same offset map the stub does: the next call can aim at
113
+ // real text instead of guessing another pattern.
114
+ const map = textMap(text);
115
+ return [
116
+ header(artifact, `no match for /${pattern}/i`),
117
+ map || 'No long text blocks -- this artifact is short lines throughout.',
118
+ 'Try a broader pattern, or read a window with offset/limit.'
119
+ ].join('\n');
148
120
  }
149
- out.push(block);
150
- used += block.length + 2;
151
- shown++;
152
- }
153
- return out.join('\n\n');
121
+ // Merge overlapping context windows so a dense run of matches prints once.
122
+ const ranges = [];
123
+ for (const i of hits) {
124
+ const from = Math.max(0, i - contextLines);
125
+ const to = Math.min(lines.length - 1, i + contextLines);
126
+ const last = ranges[ranges.length - 1];
127
+ if (last && from <= last.to + 1)
128
+ last.to = Math.max(last.to, to);
129
+ else
130
+ ranges.push({ from, to });
131
+ }
132
+ const out = [header(artifact, `${hits.length} line(s) match /${pattern}/i`)];
133
+ let used = out[0].length;
134
+ let shown = 0;
135
+ for (const r of ranges) {
136
+ const block = lines
137
+ .slice(r.from, r.to + 1)
138
+ .map((l, k) => `${String(r.from + k + 1).padStart(6)}│${l}`)
139
+ .join('\n');
140
+ if (used + block.length > budget) {
141
+ out.push(`… ${ranges.length - shown} more match group(s) omitted; narrow the pattern.`);
142
+ break;
143
+ }
144
+ out.push(block);
145
+ used += block.length + 2;
146
+ shown++;
147
+ }
148
+ return out.join('\n\n');
154
149
  }
155
-
156
150
  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}`;
151
+ const start = Math.max(0, Math.min(offset, text.length));
152
+ const size = Math.min(limit > 0 ? limit : budget, budget);
153
+ const slice = text.slice(start, start + size);
154
+ const end = start + slice.length;
155
+ const note = `chars ${start.toLocaleString('en-US')}–${end.toLocaleString('en-US')}`;
156
+ const more = end < text.length
157
+ ? `\n[… ${(text.length - end).toLocaleString('en-US')} chars remain; continue with offset=${end}.]`
158
+ : '';
159
+ return `${header(artifact, note)}\n${slice}${more}`;
166
160
  }
167
-
168
161
  /** Runs one expansion. Returns a string, like every other tool result. */
169
162
  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);
163
+ const id = String(args?.artifact_id || '').trim();
164
+ if (!id)
165
+ return 'Error: artifact_id is required.';
166
+ // Scoped to the conversation that produced it at the query level: artifact ids
167
+ // are short, and one chat must not be able to read another's tool output by guessing.
168
+ const artifact = store.messages.getArtifact(id, chatId);
169
+ if (!artifact)
170
+ return `Error: no artifact "${id}". Ids appear in the "[compacted: artifact <id> ...]" marker.`;
171
+ if (args.grep) {
172
+ const ctx = Number.isInteger(args.context_lines) ? Math.max(0, Math.min(args.context_lines, 20)) : 2;
173
+ return grepView(artifact.content, String(args.grep), ctx, budget, artifact);
174
+ }
175
+ return windowView(artifact.content, Number(args.offset) || 0, Number(args.limit) || 0, budget, artifact);
183
176
  }
@@ -1,5 +1,4 @@
1
1
  import { ALL_USERS, CORPUS } from '../store/index.js';
2
-
3
2
  /**
4
3
  * read_document -- lets the model read text the user attached to the chat.
5
4
  *
@@ -10,93 +9,89 @@ import { ALL_USERS, CORPUS } from '../store/index.js';
10
9
  * small-to-big dense / hybrid retrieval when retrieval.mode asks for it
11
10
  * (retrieval.js). The tool's interface is the same in every mode.
12
11
  */
13
-
14
12
  export const READ_DOCUMENT = 'read_document';
15
-
16
13
  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
- };
14
+ return {
15
+ type: 'function',
16
+ function: {
17
+ name: READ_DOCUMENT,
18
+ description: [
19
+ 'Read an attached document. Documents appear as',
20
+ '"[Attached document: ... (id: <id>, ...)]" notes in the conversation.',
21
+ '',
22
+ 'Give a query to search the document for relevant passages (best for',
23
+ 'long documents and specific questions), or use offset/limit to page through the',
24
+ 'raw text from the start. With neither, returns the beginning of the document.'
25
+ ].join('\n'),
26
+ parameters: {
27
+ type: 'object',
28
+ properties: {
29
+ document_id: {
30
+ type: 'string',
31
+ description: 'The id shown in the "[Attached document: ...]" note, e.g. "a1b2c3d4".'
32
+ },
33
+ query: {
34
+ type: 'string',
35
+ description: 'Free-text query. Returns the best-matching passages, ranked by relevance.'
36
+ },
37
+ offset: {
38
+ type: 'integer',
39
+ description: 'Character offset to start reading from. Ignored when query is given.'
40
+ },
41
+ limit: {
42
+ type: 'integer',
43
+ description: 'Maximum characters to return. Capped by the server budget.'
44
+ }
45
+ },
46
+ required: ['document_id']
47
+ }
48
+ }
49
+ };
53
50
  }
54
-
55
51
  function header(doc, note) {
56
- return `[document ${doc.id} · ${doc.filename} · ${doc.char_len.toLocaleString('en-US')} chars · ${note}]`;
52
+ return `[document ${doc.id} · ${doc.filename} · ${doc.char_len.toLocaleString('en-US')} chars · ${note}]`;
57
53
  }
58
-
59
54
  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;
55
+ const hits = retrieval ? await retrieval.search(CORPUS.DOCUMENTS, { docId: doc.id }, query, 5) : store.documents.searchPassages(doc.id, query, 5);
56
+ if (!hits.length) {
57
+ return `${header(doc, `no match for "${query}"`)}\nTry a broader query, or read from the start with offset/limit.`;
58
+ }
59
+ const out = [header(doc, `${hits.length} passage(s) matching "${query}"`)];
60
+ let used = out[0].length;
61
+ for (const hit of hits) {
62
+ const block = `--- passage ${hit.passageIdx} (offset ${hit.charStart.toLocaleString('en-US')}) ---\n${hit.body}`;
63
+ if (used + block.length > budget) {
64
+ out.push('… remaining matches omitted; narrow the query.');
65
+ break;
66
+ }
67
+ out.push(block);
68
+ used += block.length + 2;
71
69
  }
72
- out.push(block);
73
- used += block.length + 2;
74
- }
75
- return out.join('\n\n');
70
+ return out.join('\n\n');
76
71
  }
77
-
78
72
  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}`;
73
+ const text = doc.content;
74
+ const start = Math.max(0, Math.min(offset, text.length));
75
+ const size = Math.min(limit > 0 ? limit : budget, budget);
76
+ const slice = text.slice(start, start + size);
77
+ const end = start + slice.length;
78
+ const note = `chars ${start.toLocaleString('en-US')}–${end.toLocaleString('en-US')}`;
79
+ const more = end < text.length
80
+ ? `\n[… ${(text.length - end).toLocaleString('en-US')} chars remain; continue with offset=${end}.]`
81
+ : '';
82
+ return `${header(doc, note)}\n${slice}${more}`;
89
83
  }
90
-
91
84
  /** Runs one read. Returns a string, like every other tool result. */
92
85
  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);
86
+ const id = String(args?.document_id || '').trim();
87
+ if (!id)
88
+ return 'Error: document_id is required.';
89
+ const doc = store.documents.get(id, ALL_USERS);
90
+ if (!doc)
91
+ return `Error: no document "${id}". Ids appear in the "[Attached document: ...]" note.`;
92
+ if (doc.chat_id !== chatId)
93
+ return `Error: document "${id}" does not belong to this conversation.`;
94
+ if (args.query)
95
+ return queryView(store, retrieval, doc, String(args.query), budget);
96
+ return windowView(doc, Number(args.offset) || 0, Number(args.limit) || 0, budget);
102
97
  }