@aspalagin/openclaw-max 0.8.1 → 0.9.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/README.md +40 -3
- package/README_EN.md +40 -3
- package/SKILL.md +1 -1
- package/assets/icon.png +0 -0
- package/dist/src/accounts.js +1 -0
- package/dist/src/actions.js +8 -5
- package/dist/src/api.js +43 -4
- package/dist/src/channel-agent-prompt.js +4 -3
- package/dist/src/channel-directory.js +5 -0
- package/dist/src/channel-outbound.js +14 -3
- package/dist/src/config-schema.js +5 -1
- package/dist/src/deliver.js +14 -7
- package/dist/src/format.js +170 -13
- package/dist/src/stream-draft.js +6 -1
- package/openclaw.plugin.json +18 -6
- package/package.json +1 -1
- package/src/accounts.ts +4 -0
- package/src/actions.ts +12 -6
- package/src/api.ts +43 -9
- package/src/channel-agent-prompt.ts +4 -3
- package/src/channel-directory.ts +5 -0
- package/src/channel-outbound.ts +19 -3
- package/src/config-schema.ts +5 -1
- package/src/deliver.ts +13 -8
- package/src/format.ts +196 -13
- package/src/stream-draft.ts +6 -1
package/src/format.ts
CHANGED
|
@@ -1,32 +1,215 @@
|
|
|
1
1
|
/**
|
|
2
2
|
* Конвертер форматирования OpenClaw/стандартный markdown → MAX markdown.
|
|
3
3
|
*
|
|
4
|
-
*
|
|
5
|
-
*
|
|
6
|
-
*
|
|
7
|
-
*
|
|
8
|
-
*
|
|
9
|
-
* - bold, italic, strikethrough, code, ссылки — совпадают со стандартом.
|
|
4
|
+
* Что MAX разбирает сам (живая проба 10.10.2026, body.markup из GET /messages):
|
|
5
|
+
* `**`/`__` жирный, `*`/`_` курсив, `~~` зачёркнутый, `++` подчёркнутый,
|
|
6
|
+
* `^^` выделение, `код` и ```блоки```, ссылки, упоминания
|
|
7
|
+
* [Имя](max://user/user_id), `# заголовок` (один уровень; setext `===` тоже),
|
|
8
|
+
* `> цитата` и экранирование `\`. Это НЕ трогаем.
|
|
10
9
|
*
|
|
11
|
-
*
|
|
12
|
-
*
|
|
10
|
+
* Что конвертируем (MAX показывает сырым или теряет):
|
|
11
|
+
* - pipe-таблицы — штатным convertMarkdownTables ядра (меняет только диапазоны
|
|
12
|
+
* таблиц). Без `markdown.tables` (умолчание) каждая таблица по своей ширине:
|
|
13
|
+
* узкая — моноширинным блоком, широкая — пунктами. Явный режим — как в ядре:
|
|
14
|
+
* bullets — пункт на ячейку, code — моноширинный блок, block → code
|
|
15
|
+
* (нативных таблиц в MAX нет), off — как есть;
|
|
16
|
+
* - `##`…`######` → `#`: уровни 2–6 MAX снимает без разметки;
|
|
17
|
+
* - горизонтальные линии `---`, `***`, `___` → `───`: MAX оставляет их сырыми,
|
|
18
|
+
* `* * *` превращает во вложенные списки, а `---` под строкой текста
|
|
19
|
+
* съедает вместе с её форматом (setext-заголовок второго уровня);
|
|
20
|
+
* - `==текст==` → `^^текст^^`;
|
|
21
|
+
* - `<u>…</u>` → `++…++`: подчёркивание тегом MAX в markdown не понимает.
|
|
22
|
+
*
|
|
23
|
+
* Заголовки и линии меняются только вне fenced-блоков, инлайн-замены — вне
|
|
24
|
+
* inline-кода, fenced-блоков и URL. Повторный вызов ничего не меняет, поэтому
|
|
25
|
+
* текст можно подготовить до разбиения на части, а send.ts пройдёт по каждой
|
|
26
|
+
* части ещё раз.
|
|
27
|
+
*/
|
|
28
|
+
|
|
29
|
+
import { convertMarkdownTables, markdownToIRWithMeta } from 'openclaw/plugin-sdk/text-chunking';
|
|
30
|
+
|
|
31
|
+
/** channels.max.markdown.tables (и accounts.<id>.markdown.tables). */
|
|
32
|
+
export type MaxMarkdownTableMode = 'off' | 'bullets' | 'code' | 'block';
|
|
33
|
+
|
|
34
|
+
/** Режим таблиц для toMaxMarkdown: auto — markdown.tables не задан, режим по ширине таблицы. */
|
|
35
|
+
export type MaxTableMode = Exclude<MaxMarkdownTableMode, 'block'> | 'auto';
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Умолчание, которое видит ядро (messaging.defaultMarkdownTableMode). Режима
|
|
39
|
+
* «по ширине» в MarkdownTableMode ядра нет, а ядро по этому значению решает
|
|
40
|
+
* две вещи: добавлять ли в групповой промпт «Avoid Markdown tables» (да для
|
|
41
|
+
* всего, кроме block/off) и что вернёт resolveMarkdownTableMode для MAX без
|
|
42
|
+
* конфига. bullets: подсказка остаётся — нативных таблиц в MAX нет, широкая
|
|
43
|
+
* таблица в группе станет длинным списком; а если кто-то сконвертирует текст
|
|
44
|
+
* для MAX по этому ответу, пункты не развалятся на телефоне. block солгал бы
|
|
45
|
+
* ядру о нативных таблицах, off — о сырых.
|
|
46
|
+
*/
|
|
47
|
+
export const MAX_DECLARED_TABLE_MODE: MaxMarkdownTableMode = 'bullets';
|
|
48
|
+
|
|
49
|
+
/**
|
|
50
|
+
* Самая широкая таблица, которая без markdown.tables уходит моноширинным
|
|
51
|
+
* блоком: ширина строки блока в колонках. Блок кода в MAX не прокручивается,
|
|
52
|
+
* длинная строка переносится и таблица разваливается; на обычном телефоне
|
|
53
|
+
* в блок влезает 34–38 моноширинных символов (демо 10.10.2026). Шире — пункты.
|
|
13
54
|
*/
|
|
55
|
+
export const MAX_NARROW_TABLE_WIDTH = 36;
|
|
56
|
+
|
|
57
|
+
/**
|
|
58
|
+
* Режим таблиц аккаунта: accounts.<id> наследует channels.max.markdown
|
|
59
|
+
* (accounts.ts). Не задан — auto; block → code: нативных таблиц в MAX нет.
|
|
60
|
+
*/
|
|
61
|
+
export function resolveMaxTableMode(config?: {
|
|
62
|
+
markdown?: { tables?: MaxMarkdownTableMode };
|
|
63
|
+
}): MaxTableMode {
|
|
64
|
+
const mode = config?.markdown?.tables;
|
|
65
|
+
if (!mode) return 'auto';
|
|
66
|
+
return mode === 'block' ? 'code' : mode;
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
const graphemes = new Intl.Segmenter();
|
|
70
|
+
const WIDE_GRAPHEME =
|
|
71
|
+
/\p{Emoji_Presentation}|\p{Extended_Pictographic}\uFE0F|[\u1100-\u115F\u2E80-\u303E\u3041-\u33FF\u3400-\u4DBF\u4E00-\u9FFF\uA000-\uA4CF\uAC00-\uD7A3\uF900-\uFAFF\uFE30-\uFE4F\uFF00-\uFF60\uFFE0-\uFFE6\u{20000}-\u{3FFFD}]/u;
|
|
72
|
+
const ZERO_WIDTH_GRAPHEME = /^[\p{Mn}\p{Me}\p{Cf}]+$/u;
|
|
73
|
+
|
|
74
|
+
/** Ширина в моноширинных колонках: эмодзи и CJK — 2, метки и невидимые — 0, прочее — 1. */
|
|
75
|
+
export function displayWidth(text: string): number {
|
|
76
|
+
let width = 0;
|
|
77
|
+
for (const { segment } of graphemes.segment(text)) {
|
|
78
|
+
if (WIDE_GRAPHEME.test(segment)) width += 2;
|
|
79
|
+
else if (!ZERO_WIDTH_GRAPHEME.test(segment)) width += 1;
|
|
80
|
+
}
|
|
81
|
+
return width;
|
|
82
|
+
}
|
|
83
|
+
|
|
84
|
+
/** Ширина строки моноширинного блока таблицы, как его строит ядро: `| ячейка | … |`, колонка не уже 3. */
|
|
85
|
+
export function codeTableWidth(headers: string[], rows: string[][]): number {
|
|
86
|
+
const widths: number[] = [];
|
|
87
|
+
for (const row of [headers, ...rows]) {
|
|
88
|
+
row.forEach((cell, column) => {
|
|
89
|
+
widths[column] = Math.max(widths[column] ?? 3, displayWidth(cell));
|
|
90
|
+
});
|
|
91
|
+
}
|
|
92
|
+
return widths.reduce((sum, width) => sum + width + 3, 1);
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Режимы таблиц текста в auto: узкая — code, широкая — bullets. Таблица со
|
|
97
|
+
* ссылками — тоже bullets: в моноширинном блоке ядро оставляет от ссылки только
|
|
98
|
+
* текст, адрес теряется.
|
|
99
|
+
*/
|
|
100
|
+
function autoTableModes(markdown: string): Set<'code' | 'bullets'> {
|
|
101
|
+
if (!markdown.includes('|')) return new Set();
|
|
102
|
+
const { tables } = markdownToIRWithMeta(markdown, {
|
|
103
|
+
linkify: false,
|
|
104
|
+
autolink: false,
|
|
105
|
+
tableMode: 'block',
|
|
106
|
+
});
|
|
107
|
+
return new Set(
|
|
108
|
+
tables.map((table) =>
|
|
109
|
+
codeTableWidth(table.headers, table.rows) <= MAX_NARROW_TABLE_WIDTH &&
|
|
110
|
+
![table.headerCells, ...table.rowCells].some((row) => row.some((cell) => cell.links.length))
|
|
111
|
+
? 'code'
|
|
112
|
+
: 'bullets',
|
|
113
|
+
),
|
|
114
|
+
);
|
|
115
|
+
}
|
|
14
116
|
|
|
15
117
|
const CODE_OR_URL = /(```[\s\S]*?```|`[^`]*`|https?:\/\/\S+)/g;
|
|
16
118
|
|
|
17
|
-
/**
|
|
119
|
+
/** Открывающая/закрывающая ограда блока кода, в том числе в списке или цитате. */
|
|
120
|
+
const FENCE = /^\s*(?:>\s*)*(`{3,}|~{3,})/;
|
|
121
|
+
const SUBHEADING = /^ {0,3}#{2,6}(?=[ \t])/;
|
|
122
|
+
const THEMATIC_BREAK = /^ {0,3}([-*_])(?:[ \t]*\1){2,}[ \t]*$/;
|
|
123
|
+
const HORIZONTAL_RULE = '───';
|
|
124
|
+
const HIGHLIGHT = /(?<![\p{L}\p{N}_=])==(?=[^\s=])([^=\n]*?[^\s=])==(?![\p{L}\p{N}_=])/gu;
|
|
125
|
+
|
|
126
|
+
/** Открытая ограда ```/~~~ блока после строки `line`; undefined — вне блока. */
|
|
127
|
+
function fenceAfter(line: string, fence: string | undefined): string | undefined {
|
|
128
|
+
const match = FENCE.exec(line);
|
|
129
|
+
if (!fence) return match?.[1];
|
|
130
|
+
const closes =
|
|
131
|
+
match &&
|
|
132
|
+
match[1][0] === fence[0] &&
|
|
133
|
+
match[1].length >= fence.length &&
|
|
134
|
+
!line.slice(match[0].length).trim();
|
|
135
|
+
return closes ? undefined : fence;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/** Заголовки уровней 2–6 и горизонтальные линии; строки внутри ```/~~~ блоков не трогаются. */
|
|
139
|
+
function convertBlocks(text: string): string {
|
|
140
|
+
let fence: string | undefined;
|
|
141
|
+
return text
|
|
142
|
+
.split('\n')
|
|
143
|
+
.map((line) => {
|
|
144
|
+
const inFence = fence !== undefined;
|
|
145
|
+
fence = fenceAfter(line, fence);
|
|
146
|
+
if (inFence || fence) return line;
|
|
147
|
+
if (THEMATIC_BREAK.test(line)) return HORIZONTAL_RULE;
|
|
148
|
+
return line.replace(SUBHEADING, '#');
|
|
149
|
+
})
|
|
150
|
+
.join('\n');
|
|
151
|
+
}
|
|
152
|
+
|
|
153
|
+
/** Куски текста до пустой строки вне ```/~~~ блоков включительно; join('') возвращает текст. */
|
|
154
|
+
function splitAtBlankLines(text: string): string[] {
|
|
155
|
+
const blocks: string[] = [];
|
|
156
|
+
let block = '';
|
|
157
|
+
let fence: string | undefined;
|
|
158
|
+
for (const line of text.split(/(?<=\n)/)) {
|
|
159
|
+
block += line;
|
|
160
|
+
fence = fenceAfter(line, fence);
|
|
161
|
+
if (!fence && !line.trim()) {
|
|
162
|
+
blocks.push(block);
|
|
163
|
+
block = '';
|
|
164
|
+
}
|
|
165
|
+
}
|
|
166
|
+
return block ? [...blocks, block] : blocks;
|
|
167
|
+
}
|
|
168
|
+
|
|
169
|
+
/** auto: каждая таблица по ширине — узкая моноширинным блоком, широкая пунктами. */
|
|
170
|
+
function convertTablesAuto(text: string): string {
|
|
171
|
+
const modes = autoTableModes(text);
|
|
172
|
+
if (modes.size < 2) {
|
|
173
|
+
const [mode] = modes;
|
|
174
|
+
return mode ? convertMarkdownTables(text, mode) : text;
|
|
175
|
+
}
|
|
176
|
+
// convertMarkdownTables задаёт один режим на весь текст, поэтому разные
|
|
177
|
+
// таблицы конвертируются по кускам между пустыми строками: пустая строка
|
|
178
|
+
// таблицу завершает. Таблицу, которую кусок без контекста не распознал
|
|
179
|
+
// (глубоко вложенный список), добирает последний проход пунктами — готовые
|
|
180
|
+
// блоки и пункты он не трогает.
|
|
181
|
+
const converted = splitAtBlankLines(text)
|
|
182
|
+
.map((block) =>
|
|
183
|
+
convertMarkdownTables(block, autoTableModes(block).has('bullets') ? 'bullets' : 'code'),
|
|
184
|
+
)
|
|
185
|
+
.join('');
|
|
186
|
+
return convertMarkdownTables(converted, 'bullets');
|
|
187
|
+
}
|
|
188
|
+
|
|
189
|
+
/** Инлайн-замены сегмента вне кода и URL: <u>…</u> → ++…++, ==…== → ^^…^^. */
|
|
18
190
|
function convertSegment(text: string): string {
|
|
19
|
-
return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++');
|
|
191
|
+
return text.replace(/<u>([\s\S]*?)<\/u>/gi, '++$1++').replace(HIGHLIGHT, '^^$1^^');
|
|
20
192
|
}
|
|
21
193
|
|
|
22
194
|
/**
|
|
23
195
|
* Конвертирует OpenClaw/стандартный markdown в MAX markdown, не затрагивая
|
|
24
|
-
* содержимое inline-кода, fenced-блоков и URL.
|
|
196
|
+
* содержимое inline-кода, fenced-блоков и URL. Таблицы конвертируются, только
|
|
197
|
+
* если передан `tableMode` (режим аккаунта, resolveMaxTableMode).
|
|
25
198
|
*/
|
|
26
|
-
export function toMaxMarkdown(
|
|
199
|
+
export function toMaxMarkdown(
|
|
200
|
+
text: string,
|
|
201
|
+
options: { tableMode?: MaxMarkdownTableMode | 'auto' } = {},
|
|
202
|
+
): string {
|
|
27
203
|
if (!text) return text;
|
|
204
|
+
const { tableMode } = options;
|
|
205
|
+
const withTables =
|
|
206
|
+
tableMode === 'auto'
|
|
207
|
+
? convertTablesAuto(text)
|
|
208
|
+
: tableMode && tableMode !== 'off'
|
|
209
|
+
? convertMarkdownTables(text, tableMode === 'block' ? 'code' : tableMode)
|
|
210
|
+
: text;
|
|
28
211
|
// split с захватывающей группой: нечётные индексы — код/URL (не трогаем)
|
|
29
|
-
return
|
|
212
|
+
return convertBlocks(withTables)
|
|
30
213
|
.split(CODE_OR_URL)
|
|
31
214
|
.map((segment, index) => (index % 2 === 0 ? convertSegment(segment) : segment))
|
|
32
215
|
.join('');
|
package/src/stream-draft.ts
CHANGED
|
@@ -9,6 +9,7 @@
|
|
|
9
9
|
import type { ChannelLogSink } from 'openclaw/plugin-sdk/channel-contract';
|
|
10
10
|
|
|
11
11
|
import type { ResolvedMaxAccount } from './accounts.js';
|
|
12
|
+
import { resolveMaxTableMode, toMaxMarkdown } from './format.js';
|
|
12
13
|
import {
|
|
13
14
|
deleteMaxMessage,
|
|
14
15
|
editMaxMessage,
|
|
@@ -78,7 +79,11 @@ export function createMaxDraftStream(params: {
|
|
|
78
79
|
|
|
79
80
|
const draftUpdate = async (text: string) => {
|
|
80
81
|
if (draftStopped || !text) return;
|
|
81
|
-
|
|
82
|
+
// Formatted like the final answer (deliver.ts), so the final edit compares
|
|
83
|
+
// and replaces like with like.
|
|
84
|
+
const trimmed = toMaxMarkdown(text, {
|
|
85
|
+
tableMode: resolveMaxTableMode(account.config),
|
|
86
|
+
}).trimEnd();
|
|
82
87
|
if (!trimmed || trimmed === draftLastText) return;
|
|
83
88
|
if (trimmed.length > DRAFT_MAX_CHARS) {
|
|
84
89
|
draftStopped = true;
|