@indigoai-us/hq-cli 5.116.0 → 5.117.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/CHANGELOG.md +97 -0
- package/dist/command-catalog.generated.d.ts +58 -0
- package/dist/command-catalog.generated.js +76 -0
- package/dist/commands/bot.d.ts +140 -1
- package/dist/commands/bot.js +757 -22
- package/dist/lib/bot/api.d.ts +51 -0
- package/dist/lib/bot/api.js +32 -0
- package/dist/lib/bot/daemon.d.ts +17 -0
- package/dist/lib/bot/daemon.js +44 -3
- package/dist/lib/bot/index.d.ts +4 -0
- package/dist/lib/bot/index.js +4 -0
- package/dist/lib/bot/inflight.d.ts +14 -0
- package/dist/lib/bot/local-config.d.ts +70 -0
- package/dist/lib/bot/local-config.js +147 -0
- package/dist/lib/bot/local-name.d.ts +54 -0
- package/dist/lib/bot/local-name.js +114 -0
- package/dist/lib/bot/run.d.ts +9 -0
- package/dist/lib/bot/run.js +117 -24
- package/dist/lib/bot/runnable.d.ts +51 -0
- package/dist/lib/bot/runnable.js +65 -0
- package/dist/lib/bot/self-heal.d.ts +52 -0
- package/dist/lib/bot/self-heal.js +79 -0
- package/dist/lib/bot/split.d.ts +32 -0
- package/dist/lib/bot/split.js +241 -0
- package/package.json +1 -1
|
@@ -0,0 +1,241 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* A long reply, cut into posts HQ Cloud will actually accept.
|
|
3
|
+
*
|
|
4
|
+
* `POST /v1/notify/dm` refuses a body over DM_BODY_MAX characters with
|
|
5
|
+
* `400: Message body exceeds 4000 characters`. Nothing used to split, so a
|
|
6
|
+
* model answer over the limit was sent whole, refused, saved on the in-flight
|
|
7
|
+
* marker and re-sent identically on every poll — the answer was lost, the
|
|
8
|
+
* person was never told, and the bot asked the API to do the impossible about
|
|
9
|
+
* twice a second for as long as it ran.
|
|
10
|
+
*
|
|
11
|
+
* `splitBody` is the fix, and it is pure so it can be tested on its own:
|
|
12
|
+
*
|
|
13
|
+
* - a body at or under the limit comes back untouched, as one part
|
|
14
|
+
* - a longer body is cut on paragraph boundaries first, then on sentence
|
|
15
|
+
* boundaries, and only hard-wrapped when a single sentence is still too long
|
|
16
|
+
* - a fenced code block is kept whole when it fits; when it cannot fit, the
|
|
17
|
+
* fence is closed at the end of one part and reopened (with its language) at
|
|
18
|
+
* the start of the next, so neither part renders as broken markdown
|
|
19
|
+
* - when there is more than one part, each is prefixed `(2/3)` on its own line
|
|
20
|
+
* so the reader can see the order; the prefix is paid for out of the limit,
|
|
21
|
+
* never added on top of it
|
|
22
|
+
*
|
|
23
|
+
* Every returned part is guaranteed to be at most `max` characters long.
|
|
24
|
+
*/
|
|
25
|
+
/** The largest body HQ Cloud accepts on a DM or channel post. */
|
|
26
|
+
export const DM_BODY_MAX = 4000;
|
|
27
|
+
const FENCE_RE = /^[ \t]*(`{3,}|~{3,})(.*)$/;
|
|
28
|
+
function bare(line) {
|
|
29
|
+
return line.replace(/\r?\n$/, "");
|
|
30
|
+
}
|
|
31
|
+
/**
|
|
32
|
+
* The body as paragraphs and whole fenced code blocks, in order. Concatenating
|
|
33
|
+
* every `raw` reproduces the input exactly, so nothing is lost or reflowed
|
|
34
|
+
* before a cut is actually needed.
|
|
35
|
+
*/
|
|
36
|
+
function segmentize(body) {
|
|
37
|
+
const segments = [];
|
|
38
|
+
let buf = "";
|
|
39
|
+
let fence = null;
|
|
40
|
+
const flush = (fenceOpen) => {
|
|
41
|
+
if (buf)
|
|
42
|
+
segments.push({ raw: buf, fenceOpen });
|
|
43
|
+
buf = "";
|
|
44
|
+
};
|
|
45
|
+
for (const line of body.split(/(?<=\n)/)) {
|
|
46
|
+
const text = bare(line);
|
|
47
|
+
if (fence) {
|
|
48
|
+
buf += line;
|
|
49
|
+
const m = FENCE_RE.exec(text);
|
|
50
|
+
// A closing fence is the same character, at least as long, and bare.
|
|
51
|
+
if (m && m[1][0] === fence.marker[0] && m[1].length >= fence.marker.length && m[2].trim() === "") {
|
|
52
|
+
flush(fence.open);
|
|
53
|
+
fence = null;
|
|
54
|
+
}
|
|
55
|
+
continue;
|
|
56
|
+
}
|
|
57
|
+
const m = FENCE_RE.exec(text);
|
|
58
|
+
if (m) {
|
|
59
|
+
flush(null);
|
|
60
|
+
fence = { open: text.trimEnd(), marker: m[1] };
|
|
61
|
+
buf = line;
|
|
62
|
+
continue;
|
|
63
|
+
}
|
|
64
|
+
if (text.trim() === "") {
|
|
65
|
+
// A blank line closes the paragraph it follows.
|
|
66
|
+
if (buf)
|
|
67
|
+
buf += line;
|
|
68
|
+
else
|
|
69
|
+
buf = line;
|
|
70
|
+
flush(null);
|
|
71
|
+
continue;
|
|
72
|
+
}
|
|
73
|
+
buf += line;
|
|
74
|
+
}
|
|
75
|
+
flush(fence ? fence.open : null);
|
|
76
|
+
return segments;
|
|
77
|
+
}
|
|
78
|
+
/** Break points inside a paragraph: after sentence-ending punctuation, or at a line end. */
|
|
79
|
+
function splitSentences(text) {
|
|
80
|
+
const parts = [];
|
|
81
|
+
const re = /(?:[.!?…]["'”’)\]]*|\n)[ \t]*\n?[ \t]*/g;
|
|
82
|
+
let start = 0;
|
|
83
|
+
let m;
|
|
84
|
+
while ((m = re.exec(text)) !== null) {
|
|
85
|
+
const end = m.index + m[0].length;
|
|
86
|
+
if (end <= start) {
|
|
87
|
+
re.lastIndex = start + 1;
|
|
88
|
+
continue;
|
|
89
|
+
}
|
|
90
|
+
parts.push(text.slice(start, end));
|
|
91
|
+
start = end;
|
|
92
|
+
}
|
|
93
|
+
if (start < text.length)
|
|
94
|
+
parts.push(text.slice(start));
|
|
95
|
+
return parts.length > 0 ? parts : [text];
|
|
96
|
+
}
|
|
97
|
+
/** Last resort: a run of text with no usable break, cut at whitespace when there is any. */
|
|
98
|
+
function hardWrap(text, budget) {
|
|
99
|
+
const out = [];
|
|
100
|
+
let rest = text;
|
|
101
|
+
while (rest.length > budget) {
|
|
102
|
+
const window = rest.slice(0, budget);
|
|
103
|
+
const ws = Math.max(window.lastIndexOf(" "), window.lastIndexOf("\n"));
|
|
104
|
+
const cut = ws > 0 && ws >= Math.floor(budget * 0.6) ? ws : budget;
|
|
105
|
+
const piece = rest.slice(0, cut).trim();
|
|
106
|
+
if (piece)
|
|
107
|
+
out.push(piece);
|
|
108
|
+
rest = rest.slice(cut).replace(/^[ \t\n]+/, "");
|
|
109
|
+
}
|
|
110
|
+
const tail = rest.trim();
|
|
111
|
+
if (tail)
|
|
112
|
+
out.push(tail);
|
|
113
|
+
return out;
|
|
114
|
+
}
|
|
115
|
+
/** A code block too long for one part: closed and reopened around every cut. */
|
|
116
|
+
function splitFenced(segment, budget) {
|
|
117
|
+
const open = segment.fenceOpen ?? "```";
|
|
118
|
+
const marker = FENCE_RE.exec(open)?.[1] ?? "```";
|
|
119
|
+
const overhead = open.length + marker.length + 2; // the two fence lines and their newlines
|
|
120
|
+
const inner = budget - overhead;
|
|
121
|
+
if (inner < 1)
|
|
122
|
+
return splitPlain(segment.raw, budget);
|
|
123
|
+
const lines = segment.raw.split(/(?<=\n)/).slice(1);
|
|
124
|
+
const last = lines[lines.length - 1];
|
|
125
|
+
if (last !== undefined) {
|
|
126
|
+
const m = FENCE_RE.exec(bare(last));
|
|
127
|
+
if (m && m[1][0] === marker[0] && m[2].trim() === "")
|
|
128
|
+
lines.pop();
|
|
129
|
+
}
|
|
130
|
+
const pieces = [];
|
|
131
|
+
let buf = "";
|
|
132
|
+
const emit = () => {
|
|
133
|
+
const code = buf.replace(/\n+$/, "");
|
|
134
|
+
buf = "";
|
|
135
|
+
if (code.trim() === "")
|
|
136
|
+
return;
|
|
137
|
+
pieces.push(`${open}\n${code}\n${marker}`);
|
|
138
|
+
};
|
|
139
|
+
for (const line of lines) {
|
|
140
|
+
if ((buf + line).replace(/\n+$/, "").length <= inner) {
|
|
141
|
+
buf += line;
|
|
142
|
+
continue;
|
|
143
|
+
}
|
|
144
|
+
emit();
|
|
145
|
+
const one = bare(line);
|
|
146
|
+
if (one.length <= inner) {
|
|
147
|
+
buf = line;
|
|
148
|
+
continue;
|
|
149
|
+
}
|
|
150
|
+
for (const wrapped of hardWrap(one, inner)) {
|
|
151
|
+
buf = wrapped;
|
|
152
|
+
emit();
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
emit();
|
|
156
|
+
return pieces.length > 0 ? pieces : splitPlain(segment.raw, budget);
|
|
157
|
+
}
|
|
158
|
+
/** A paragraph too long for one part: sentences first, hard wrap only if one sentence is still too long. */
|
|
159
|
+
function splitPlain(text, budget) {
|
|
160
|
+
const out = [];
|
|
161
|
+
let buf = "";
|
|
162
|
+
const flush = () => {
|
|
163
|
+
const t = buf.trim();
|
|
164
|
+
buf = "";
|
|
165
|
+
if (t)
|
|
166
|
+
out.push(t);
|
|
167
|
+
};
|
|
168
|
+
for (const sentence of splitSentences(text)) {
|
|
169
|
+
if ((buf + sentence).trim().length <= budget) {
|
|
170
|
+
buf += sentence;
|
|
171
|
+
continue;
|
|
172
|
+
}
|
|
173
|
+
flush();
|
|
174
|
+
if (sentence.trim().length <= budget) {
|
|
175
|
+
buf = sentence;
|
|
176
|
+
continue;
|
|
177
|
+
}
|
|
178
|
+
for (const wrapped of hardWrap(sentence.trim(), budget))
|
|
179
|
+
out.push(wrapped);
|
|
180
|
+
}
|
|
181
|
+
flush();
|
|
182
|
+
return out.length > 0 ? out : hardWrap(text.trim(), budget);
|
|
183
|
+
}
|
|
184
|
+
function pack(body, budget) {
|
|
185
|
+
const out = [];
|
|
186
|
+
let acc = "";
|
|
187
|
+
const flush = () => {
|
|
188
|
+
const t = acc.trim();
|
|
189
|
+
acc = "";
|
|
190
|
+
if (t)
|
|
191
|
+
out.push(t);
|
|
192
|
+
};
|
|
193
|
+
for (const segment of segmentize(body)) {
|
|
194
|
+
if ((acc + segment.raw).trim().length <= budget) {
|
|
195
|
+
acc += segment.raw;
|
|
196
|
+
continue;
|
|
197
|
+
}
|
|
198
|
+
flush();
|
|
199
|
+
if (segment.raw.trim().length <= budget) {
|
|
200
|
+
acc = segment.raw;
|
|
201
|
+
continue;
|
|
202
|
+
}
|
|
203
|
+
for (const piece of segment.fenceOpen ? splitFenced(segment, budget) : splitPlain(segment.raw, budget)) {
|
|
204
|
+
out.push(piece);
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
flush();
|
|
208
|
+
return out;
|
|
209
|
+
}
|
|
210
|
+
/** `(2/3)` and its newline, at the widest the count can make it. */
|
|
211
|
+
function prefixWidth(count) {
|
|
212
|
+
return `(${count}/${count})\n`.length;
|
|
213
|
+
}
|
|
214
|
+
function label(parts) {
|
|
215
|
+
if (parts.length < 2)
|
|
216
|
+
return parts;
|
|
217
|
+
return parts.map((part, i) => `(${i + 1}/${parts.length})\n${part}`);
|
|
218
|
+
}
|
|
219
|
+
/**
|
|
220
|
+
* One outbound body as the ordered parts to post, each at most `max`
|
|
221
|
+
* characters. A body that already fits comes back unchanged.
|
|
222
|
+
*/
|
|
223
|
+
export function splitBody(body, max = DM_BODY_MAX) {
|
|
224
|
+
const limit = Math.max(1, Math.floor(max));
|
|
225
|
+
if (body.length <= limit)
|
|
226
|
+
return [body];
|
|
227
|
+
let reserve = prefixWidth(2);
|
|
228
|
+
let parts = [];
|
|
229
|
+
for (let round = 0; round < 6; round += 1) {
|
|
230
|
+
parts = pack(body, Math.max(1, limit - reserve));
|
|
231
|
+
const needed = parts.length > 1 ? prefixWidth(parts.length) : 0;
|
|
232
|
+
if (needed <= reserve)
|
|
233
|
+
break;
|
|
234
|
+
reserve = needed;
|
|
235
|
+
}
|
|
236
|
+
if (parts.length === 0)
|
|
237
|
+
parts = [body.trim() || body];
|
|
238
|
+
// Belt and braces: whatever the shape of the input, no part may exceed the limit.
|
|
239
|
+
return label(parts).flatMap((part) => (part.length <= limit ? [part] : hardWrap(part, limit)));
|
|
240
|
+
}
|
|
241
|
+
//# sourceMappingURL=split.js.map
|