@ganju/utils 0.0.9 → 0.0.11

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.
@@ -0,0 +1,217 @@
1
+ "use strict";
2
+ // When an organization turns on "confirm sensitive actions", a channel bot that
3
+ // wants to run a sensitive tool asks first, and the call runs only if the
4
+ // participant's next message is a plain yes. These are the pieces of that rule
5
+ // that aren't the runner's own bookkeeping.
6
+ Object.defineProperty(exports, "__esModule", { value: true });
7
+ exports.formatConfirmationSummary = exports.isConfirmationReply = exports.parseConfirmationButton = exports.confirmationButtonId = exports.confirmationButtonLabels = exports.languageFromPhoneNumber = exports.annotationsForEffect = exports.TOOL_EFFECTS = void 0;
8
+ // What an owner says a tool they wrote does, in the words the dashboard asks
9
+ // it in, and the MCP annotations each answer stands for: `read` only looks
10
+ // things up, `write` changes things that can be undone, `sensitive` sends,
11
+ // deletes, charges or can't be undone — the kind a confirming channel asks
12
+ // about.
13
+ exports.TOOL_EFFECTS = ['read', 'write', 'sensitive'];
14
+ const annotationsForEffect = (effect) => effect === 'read'
15
+ ? { readOnlyHint: true, destructiveHint: false }
16
+ : effect === 'write'
17
+ ? { readOnlyHint: false, destructiveHint: false }
18
+ : { readOnlyHint: false, destructiveHint: true };
19
+ exports.annotationsForEffect = annotationsForEffect;
20
+ // Whole replies that mean yes, lowercased with accents and punctuation
21
+ // removed. English, Spanish and Portuguese, the languages bots here answer in.
22
+ const AFFIRMATIVE = new Set([
23
+ 'y',
24
+ 'yes',
25
+ 'yeah',
26
+ 'yep',
27
+ 'yup',
28
+ 'sure',
29
+ 'ok',
30
+ 'okay',
31
+ 'confirm',
32
+ 'confirmed',
33
+ 'approve',
34
+ 'approved',
35
+ 'go',
36
+ 'go ahead',
37
+ 'do it',
38
+ 'send',
39
+ 'send it',
40
+ 'proceed',
41
+ 'yes please',
42
+ 'yes send it',
43
+ 'yes do it',
44
+ 'yes go ahead',
45
+ 'yes confirm',
46
+ 'sure thing',
47
+ 'go for it',
48
+ 'sounds good',
49
+ 'looks good',
50
+ 'all good',
51
+ 'correct',
52
+ 'si hazlo',
53
+ 'si adelante',
54
+ 'si envialo ya',
55
+ 'perfecto',
56
+ 'correcto',
57
+ 'esta bien',
58
+ 'ok dale',
59
+ 'ok envialo',
60
+ 'si',
61
+ 'sip',
62
+ 'claro',
63
+ 'dale',
64
+ 'va',
65
+ 'vale',
66
+ 'listo',
67
+ 'confirmo',
68
+ 'confirmado',
69
+ 'confirmar',
70
+ 'adelante',
71
+ 'hazlo',
72
+ 'envialo',
73
+ 'enviar',
74
+ 'de acuerdo',
75
+ 'si por favor',
76
+ 'si claro',
77
+ 'si dale',
78
+ 'si envialo',
79
+ 'si confirmo',
80
+ 'sim',
81
+ 'pode',
82
+ 'pode enviar'
83
+ ]);
84
+ const AFFIRMATIVE_EMOJI = new Set(['👍', '✅', '👌', '🆗']);
85
+ // The Yes/No buttons offered under a confirmation question, in the
86
+ // participant's language when the platform reports it. The labels are also
87
+ // what a tap sends where a button is just a canned reply (Telegram's reply
88
+ // keyboard), so "yes" must read as a yes to isConfirmationReply — and it does,
89
+ // in every language here.
90
+ const BUTTON_LABELS = {
91
+ en: { yes: '✅ Yes', no: '❌ No' },
92
+ es: { yes: '✅ Sí', no: '❌ No' },
93
+ pt: { yes: '✅ Sim', no: '❌ Não' }
94
+ };
95
+ // WhatsApp reports no locale, so a button's language comes from the calling
96
+ // code of the participant's number: the Spanish- and Portuguese-speaking
97
+ // countries, English otherwise. Longest prefix wins.
98
+ const CALLING_CODE_LANGUAGE = {
99
+ '34': 'es',
100
+ '51': 'es',
101
+ '52': 'es',
102
+ '53': 'es',
103
+ '54': 'es',
104
+ '56': 'es',
105
+ '57': 'es',
106
+ '58': 'es',
107
+ '240': 'es',
108
+ '502': 'es',
109
+ '503': 'es',
110
+ '504': 'es',
111
+ '505': 'es',
112
+ '506': 'es',
113
+ '507': 'es',
114
+ '591': 'es',
115
+ '593': 'es',
116
+ '595': 'es',
117
+ '598': 'es',
118
+ '1787': 'es',
119
+ '1809': 'es',
120
+ '1829': 'es',
121
+ '1849': 'es',
122
+ '55': 'pt',
123
+ '238': 'pt',
124
+ '244': 'pt',
125
+ '245': 'pt',
126
+ '258': 'pt',
127
+ '351': 'pt'
128
+ };
129
+ const languageFromPhoneNumber = (phone) => {
130
+ const digits = phone.replace(/\D/g, '');
131
+ for (let length = 4; length >= 2; length--) {
132
+ const language = CALLING_CODE_LANGUAGE[digits.slice(0, length)];
133
+ if (language)
134
+ return language;
135
+ }
136
+ return null;
137
+ };
138
+ exports.languageFromPhoneNumber = languageFromPhoneNumber;
139
+ const confirmationButtonLabels = (locale) => {
140
+ const language = (locale || '').toLowerCase().split(/[-_]/)[0];
141
+ return BUTTON_LABELS[language] ?? BUTTON_LABELS.en;
142
+ };
143
+ exports.confirmationButtonLabels = confirmationButtonLabels;
144
+ // Where the platform sends back a button's id rather than its label (Discord,
145
+ // WhatsApp), the id says which answer it is and who was asked: in a shared
146
+ // channel everyone sees the buttons, and only that participant's tap counts.
147
+ // The runner never sees the id — a tap reaches it as the text "yes" or "no".
148
+ const CONFIRMATION_BUTTON_PREFIX = 'ganju-confirm';
149
+ const confirmationButtonId = (answer, externalParticipantId) => `${CONFIRMATION_BUTTON_PREFIX}:${answer}:${externalParticipantId}`;
150
+ exports.confirmationButtonId = confirmationButtonId;
151
+ const parseConfirmationButton = (id) => {
152
+ const [prefix, answer, ...rest] = id.split(':');
153
+ const externalParticipantId = rest.join(':');
154
+ if (prefix !== CONFIRMATION_BUTTON_PREFIX || !externalParticipantId) {
155
+ return null;
156
+ }
157
+ if (answer !== 'yes' && answer !== 'no')
158
+ return null;
159
+ return { text: answer, externalParticipantId };
160
+ };
161
+ exports.parseConfirmationButton = parseConfirmationButton;
162
+ const normalize = (text) => text
163
+ .toLowerCase()
164
+ .normalize('NFKD')
165
+ .replace(/[̀-ͯ]/g, '')
166
+ .replace(/[^a-z0-9\s]+/g, ' ')
167
+ .replace(/\s+/g, ' ')
168
+ .trim();
169
+ /**
170
+ * Whether a reply confirms the pending action. Deliberately narrow: the whole
171
+ * message has to be a yes. "Yes, but send it to Ana instead" is not one — it
172
+ * asks for a different action, and running the original would do exactly
173
+ * what the person just said not to. Anything that isn't a clear yes cancels,
174
+ * so the worst a misread costs is asking again.
175
+ */
176
+ const isConfirmationReply = (text) => {
177
+ const trimmed = text.trim();
178
+ if (AFFIRMATIVE_EMOJI.has(trimmed))
179
+ return true;
180
+ return AFFIRMATIVE.has(normalize(trimmed));
181
+ };
182
+ exports.isConfirmationReply = isConfirmationReply;
183
+ // How much of each argument the summary shows, and how many arguments.
184
+ const SUMMARY_VALUE_MAX = 200;
185
+ const SUMMARY_ARGUMENTS_MAX = 10;
186
+ const summaryValue = (value) => {
187
+ const text = typeof value === 'string'
188
+ ? value
189
+ : (JSON.stringify(value) ?? String(value));
190
+ // One line, and no backticks, so a value can neither fake another line of
191
+ // the summary nor break out of its code span into formatting or links.
192
+ const flat = text.replace(/\s+/g, ' ').replace(/`/g, "'").trim();
193
+ return flat.length > SUMMARY_VALUE_MAX
194
+ ? `${flat.slice(0, SUMMARY_VALUE_MAX)}…`
195
+ : flat;
196
+ };
197
+ /**
198
+ * What the held calls will do, written from the calls themselves and appended
199
+ * under the model's question. The model words the question, but a yes runs the
200
+ * stored calls — so the participant has to see those, not only the model's
201
+ * account of them, which a prompt injection earlier in the turn could have
202
+ * bent.
203
+ */
204
+ const formatConfirmationSummary = (calls, titleOf) => calls
205
+ .map(call => {
206
+ const entries = Object.entries(call.arguments ?? {}).filter(([, value]) => value !== undefined && value !== null && value !== '');
207
+ const lines = entries
208
+ .slice(0, SUMMARY_ARGUMENTS_MAX)
209
+ .map(([key, value]) => `- ${key}: \`${summaryValue(value)}\``);
210
+ if (entries.length > SUMMARY_ARGUMENTS_MAX)
211
+ lines.push('- …');
212
+ // The hourglass says "not done yet" in every language, whatever the
213
+ // model's own words above it claim.
214
+ return [`⏳ **${titleOf(call.name)}**`, ...lines].join('\n');
215
+ })
216
+ .join('\n\n');
217
+ exports.formatConfirmationSummary = formatConfirmationSummary;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@ganju/utils",
3
- "version": "0.0.9",
3
+ "version": "0.0.11",
4
4
  "description": "Shared constants, schemas and helpers for Ganju",
5
5
  "license": "Apache-2.0",
6
6
  "author": "MontoyaAndres <andresmontoyafcb@gmail.com>",
@@ -12,8 +12,41 @@ export interface BufferedChannelMessage {
12
12
  text: string;
13
13
  externalMessageId: string | null;
14
14
  receivedAt: number;
15
+ // Answer without waiting for the burst to settle: an explicit command, or a
16
+ // message on a channel with buffering turned off. It still goes through the
17
+ // buffer — that is what runs the turn under a caller patient enough for a
18
+ // long answer — it just flushes at once, taking any pending text with it.
19
+ immediate?: boolean;
20
+ // The prompt this message invoked, when it was a slash command.
21
+ command?: BufferedChannelCommand | null;
22
+ // Where this message must be answered, when that isn't the envelope's
23
+ // `delivery` — a Discord interaction waiting on its deferred response. Kept
24
+ // on the message because the envelope is replaced by every newer push, and a
25
+ // plain message typed a moment later must not orphan the interaction.
26
+ reply?: Record<string, unknown> | null;
15
27
  }
16
28
 
29
+ // A resolved slash-command prompt, carried with the message that invoked it so
30
+ // the flush can run it exactly as the webhook would have.
31
+ export interface BufferedChannelCommand {
32
+ promptId: string;
33
+ artifactPromptId: string | null;
34
+ promptTitle: string;
35
+ args: Record<string, string>;
36
+ }
37
+
38
+ // The prompt a batch runs: the newest command in it, since that is the one the
39
+ // participant asked for last. Null for a batch of plain text.
40
+ export const batchCommand = (
41
+ messages: BufferedChannelMessage[]
42
+ ): BufferedChannelCommand | null => {
43
+ for (let i = messages.length - 1; i >= 0; i--) {
44
+ const command = messages[i].command;
45
+ if (command) return command;
46
+ }
47
+ return null;
48
+ };
49
+
17
50
  // Everything the flush needs to run the turn and deliver the reply, minus the
18
51
  // message texts themselves. The newest push wins: a display name or thread id
19
52
  // can change mid-burst, and the freshest is the one to answer against.
@@ -35,6 +68,9 @@ export interface ChannelBufferEnvelope {
35
68
  export interface ChannelBufferFlush {
36
69
  envelope: ChannelBufferEnvelope;
37
70
  messages: BufferedChannelMessage[];
71
+ // Stable across every hand-off of the same batch, so the worker can tell a
72
+ // retry of a turn it already answered from a new one.
73
+ batchId: string;
38
74
  }
39
75
 
40
76
  // Read the channel's debounce window off its `config`, clamped to the supported
package/src/chunking.ts CHANGED
@@ -1,4 +1,5 @@
1
1
  import { constants } from './constants';
2
+ import { decodeEntities } from './sanitize';
2
3
 
3
4
  import type {
4
5
  ExtractedDocument,
@@ -50,24 +51,140 @@ export const splitRecursive = (
50
51
  return chunks;
51
52
  };
52
53
 
53
- export const chunkText = (
54
+ // Each piece keeps its `raw` text — the slice of the input it covers, without
55
+ // the overlap borrowed from the piece before — so a caller can walk the input
56
+ // in order without reading any of it twice.
57
+ const chunkPieces = (
54
58
  text: string,
55
59
  options?: { targetChars?: number; overlapChars?: number }
56
- ): string[] => {
60
+ ): Array<{ raw: string; content: string }> => {
57
61
  const target = options?.targetChars ?? constants.CHUNK_TARGET_CHARS;
58
62
  const overlap = options?.overlapChars ?? constants.CHUNK_OVERLAP_CHARS;
59
63
 
60
64
  const trimmed = text.trim();
61
65
  if (!trimmed) return [];
62
- if (trimmed.length <= target) return [trimmed];
66
+ if (trimmed.length <= target) return [{ raw: trimmed, content: trimmed }];
63
67
 
64
68
  const rawChunks = splitRecursive(trimmed, target);
65
- const withOverlap: string[] = [];
69
+ const pieces: Array<{ raw: string; content: string }> = [];
66
70
  for (let i = 0; i < rawChunks.length; i++) {
67
71
  const prev = i > 0 ? rawChunks[i - 1].slice(-overlap) : '';
68
- withOverlap.push((prev ? prev + '\n' : '') + rawChunks[i]);
72
+ pieces.push({
73
+ raw: rawChunks[i],
74
+ content: (prev ? prev + '\n' : '') + rawChunks[i]
75
+ });
76
+ }
77
+ return pieces.filter(p => p.content.trim().length > 0);
78
+ };
79
+
80
+ export const chunkText = (
81
+ text: string,
82
+ options?: { targetChars?: number; overlapChars?: number }
83
+ ): string[] => chunkPieces(text, options).map(p => p.content);
84
+
85
+ export type HeadingSyntax = 'markdown' | 'html';
86
+
87
+ interface HeadingEvent {
88
+ offset: number;
89
+ level: number;
90
+ text: string;
91
+ }
92
+
93
+ const MARKDOWN_HEADING = /^ {0,3}(#{1,6})[ \t]+(.+?)(?:[ \t]+#+)?[ \t]*$/;
94
+ const MARKDOWN_FENCE = /^ {0,3}(```|~~~)/;
95
+ const HTML_HEADING = /<h([1-6])\b[^>]*>([\s\S]*?)<\/h\1\s*>/gi;
96
+ const MAX_HEADING_CHARS = 120;
97
+
98
+ const MARKDOWN_FILE_NAME = /\.(md|markdown|mdx)$/i;
99
+
100
+ /**
101
+ * Which heading syntax a document is written in, or null when it has none we
102
+ * can trust. Plain text is left out on purpose: a line that happens to start
103
+ * with "# " there is as likely a comment or a list as a title.
104
+ */
105
+ export const headingSyntaxFor = (input: {
106
+ mimeType?: string | null;
107
+ fileName?: string | null;
108
+ isWebPage?: boolean;
109
+ }): HeadingSyntax | null => {
110
+ // Crawled pages are flattened to text with their headings written back as
111
+ // Markdown lines, whatever the page's own content type was.
112
+ if (input.isWebPage) return 'markdown';
113
+ if (input.mimeType === constants.MIMETYPE_TEXT_MARKDOWN) return 'markdown';
114
+ if (input.mimeType === constants.MIMETYPE_TEXT_HTML) return 'html';
115
+ if (input.fileName && MARKDOWN_FILE_NAME.test(input.fileName)) {
116
+ return 'markdown';
117
+ }
118
+ return null;
119
+ };
120
+
121
+ const cleanHeading = (text: string): string =>
122
+ text.replace(/\s+/g, ' ').trim().slice(0, MAX_HEADING_CHARS);
123
+
124
+ const scanMarkdownHeadings = (
125
+ text: string,
126
+ state: { inFence: boolean }
127
+ ): HeadingEvent[] => {
128
+ const events: HeadingEvent[] = [];
129
+ let offset = 0;
130
+ for (const line of text.split('\n')) {
131
+ // A `#` inside a fenced block is a shell comment or a preprocessor line,
132
+ // not a title — and a fence can open in one chunk and close in the next.
133
+ if (MARKDOWN_FENCE.test(line)) {
134
+ state.inFence = !state.inFence;
135
+ } else if (!state.inFence) {
136
+ const match = MARKDOWN_HEADING.exec(line);
137
+ const heading = match ? cleanHeading(match[2]) : '';
138
+ if (match && heading) {
139
+ events.push({ offset, level: match[1].length, text: heading });
140
+ }
141
+ }
142
+ offset += line.length + 1;
69
143
  }
70
- return withOverlap.filter(c => c.trim().length > 0);
144
+ return events;
145
+ };
146
+
147
+ const scanHtmlHeadings = (text: string): HeadingEvent[] =>
148
+ [...text.matchAll(HTML_HEADING)]
149
+ .map(match => ({
150
+ offset: match.index ?? 0,
151
+ level: Number(match[1]),
152
+ text: cleanHeading(decodeEntities(match[2].replace(/<[^>]*>/g, ' ')))
153
+ }))
154
+ .filter(event => event.text);
155
+
156
+ /**
157
+ * Follows the heading hierarchy across a document's chunks, fed in order.
158
+ * Returns, for each chunk, the path of headings it sits under — the section a
159
+ * reader would be pointed to. A chunk that opens with a heading belongs to that
160
+ * heading; one that starts mid-section belongs to the section it continues,
161
+ * even when a later heading begins inside it.
162
+ */
163
+ const createHeadingTracker = (syntax: HeadingSyntax) => {
164
+ let stack: Array<{ level: number; text: string }> = [];
165
+ const fence = { inFence: false };
166
+ const apply = (event: HeadingEvent) => {
167
+ stack = stack.filter(h => h.level < event.level);
168
+ stack.push({ level: event.level, text: event.text });
169
+ };
170
+
171
+ return (raw: string): string[] => {
172
+ const events =
173
+ syntax === 'markdown'
174
+ ? scanMarkdownHeadings(raw, fence)
175
+ : scanHtmlHeadings(raw);
176
+ const [first, ...rest] = events;
177
+ // Before a document's first heading there is no section to continue — only
178
+ // the metadata header, or the markup around an HTML body — so a chunk that
179
+ // holds the first heading belongs to it wherever in the chunk it falls.
180
+ const opensWithHeading =
181
+ first !== undefined &&
182
+ (stack.length === 0 || raw.slice(0, first.offset).trim() === '');
183
+ if (opensWithHeading) apply(first);
184
+ const path = stack.map(h => h.text);
185
+ for (const event of opensWithHeading ? rest : events) apply(event);
186
+ return path;
187
+ };
71
188
  };
72
189
 
73
190
  export const buildHeader = (input: {
@@ -87,9 +204,14 @@ export const buildHeader = (input: {
87
204
  .filter(Boolean)
88
205
  .join('\n');
89
206
 
90
- export interface ChunkMetadata extends ExtractedDocumentMetadata {
91
- chunkIndexInPage: number;
92
- chunksInPage: number;
207
+ // Loose on purpose: a chunk of inline text has no extracted document behind it,
208
+ // so only `headingPath` can be known about it.
209
+ export interface ChunkMetadata extends Partial<ExtractedDocumentMetadata> {
210
+ chunkIndexInPage?: number;
211
+ chunksInPage?: number;
212
+ // The headings this chunk sits under, outermost first. Present only for
213
+ // Markdown and HTML, and only when the chunk is under at least one heading.
214
+ headingPath?: string[];
93
215
  }
94
216
 
95
217
  export interface PreparedChunk {
@@ -97,28 +219,43 @@ export interface PreparedChunk {
97
219
  metadata: ChunkMetadata | null;
98
220
  }
99
221
 
222
+ const CONTROL_CHARS = /[\x00-\x08\x0B\x0C\x0E-\x1F]/g;
223
+
100
224
  export const prepareChunks = (
101
225
  header: string,
102
226
  documents: ExtractedDocument[] | null,
103
227
  fallbackContent: string | null,
104
- options?: { targetChars?: number; overlapChars?: number }
228
+ options?: {
229
+ targetChars?: number;
230
+ overlapChars?: number;
231
+ // The resource's own type and file name, which decide the heading syntax
232
+ // for inline content and for documents that don't carry their own.
233
+ mimeType?: string | null;
234
+ fileName?: string | null;
235
+ }
105
236
  ): PreparedChunk[] => {
106
237
  if (documents && documents.length > 0) {
107
238
  const out: PreparedChunk[] = [];
108
239
  documents.forEach((doc, docIndex) => {
109
- const body = (doc.pageContent || '')
110
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
111
- .trim();
240
+ const body = (doc.pageContent || '').replace(CONTROL_CHARS, '').trim();
112
241
  if (!body) return;
113
242
  const text = docIndex === 0 && header ? `${header}\n\n${body}` : body;
114
- const pieces = chunkText(text, options);
243
+ const syntax = headingSyntaxFor({
244
+ mimeType: doc.metadata?.source?.mimeType ?? options?.mimeType,
245
+ fileName: doc.metadata?.source?.fileName ?? options?.fileName,
246
+ isWebPage: !!doc.metadata?.web
247
+ });
248
+ const headingPathOf = syntax ? createHeadingTracker(syntax) : null;
249
+ const pieces = chunkPieces(text, options);
115
250
  pieces.forEach((piece, pieceIndex) => {
251
+ const headingPath = headingPathOf?.(piece.raw) ?? [];
116
252
  out.push({
117
- content: piece,
253
+ content: piece.content,
118
254
  metadata: {
119
255
  ...doc.metadata,
120
256
  chunkIndexInPage: pieceIndex,
121
- chunksInPage: pieces.length
257
+ chunksInPage: pieces.length,
258
+ ...(headingPath.length > 0 ? { headingPath } : {})
122
259
  }
123
260
  });
124
261
  });
@@ -126,12 +263,18 @@ export const prepareChunks = (
126
263
  return out;
127
264
  }
128
265
 
129
- const body = (fallbackContent || '')
130
- .replace(/[\x00-\x08\x0B\x0C\x0E-\x1F]/g, '')
131
- .trim();
266
+ const body = (fallbackContent || '').replace(CONTROL_CHARS, '').trim();
132
267
  const fullText = [header, body].filter(Boolean).join('\n\n');
133
- return chunkText(fullText, options).map(content => ({
134
- content,
135
- metadata: null
136
- }));
268
+ const syntax = headingSyntaxFor({
269
+ mimeType: options?.mimeType,
270
+ fileName: options?.fileName
271
+ });
272
+ const headingPathOf = syntax ? createHeadingTracker(syntax) : null;
273
+ return chunkPieces(fullText, options).map(piece => {
274
+ const headingPath = headingPathOf?.(piece.raw) ?? [];
275
+ return {
276
+ content: piece.content,
277
+ metadata: headingPath.length > 0 ? { headingPath } : null
278
+ };
279
+ });
137
280
  };