pi-lxmf 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/src/text.js ADDED
@@ -0,0 +1,97 @@
1
+ /**
2
+ * @file text.js
3
+ *
4
+ * Pure text helpers shared by the bridge and the LXMF sender: reply
5
+ * chunking and human-friendly formatting. Kept dependency-free so tests
6
+ * stay light.
7
+ */
8
+
9
+ /**
10
+ * Splits `text` into chunks of at most `maxChars` characters, preferring
11
+ * paragraph (`\n\n`) boundaries, then single newlines, then hard cuts. Only
12
+ * the *content* is measured; the `[… n/N]` markers are appended by the
13
+ * caller (the sender) so chunk sizes stay predictable here.
14
+ *
15
+ * @param {string} text
16
+ * @param {number} maxChars - Maximum characters per chunk (>= 1).
17
+ * @returns {string[]}
18
+ */
19
+ export function chunkText(text, maxChars) {
20
+ if (!Number.isFinite(maxChars) || maxChars < 1) {
21
+ throw new Error(`maxChars must be a positive number, got ${maxChars}`);
22
+ }
23
+ const trimmed = text.trim();
24
+ if (trimmed.length === 0) return [];
25
+ if (trimmed.length <= maxChars) return [trimmed];
26
+
27
+ /** @type {string[]} */
28
+ const chunks = [];
29
+ let rest = trimmed;
30
+ while (rest.length > maxChars) {
31
+ const window = rest.slice(0, maxChars);
32
+ let cut = window.lastIndexOf("\n\n");
33
+ if (cut < maxChars * 0.5) cut = window.lastIndexOf("\n");
34
+ if (cut < maxChars * 0.5) cut = window.lastIndexOf(" ");
35
+ if (cut < maxChars * 0.5) cut = -1;
36
+ if (cut === -1) {
37
+ chunks.push(window);
38
+ rest = rest.slice(maxChars);
39
+ } else {
40
+ chunks.push(window.slice(0, cut));
41
+ rest = rest.slice(cut).replace(/^\n+/, "");
42
+ }
43
+ }
44
+ if (rest.length > 0) chunks.push(rest);
45
+ return chunks.filter((c) => c.length > 0);
46
+ }
47
+
48
+ /**
49
+ * Formats an unknown thrown value as a short message.
50
+ *
51
+ * @param {unknown} e
52
+ * @returns {string}
53
+ */
54
+ export function errorText(e) {
55
+ if (e instanceof Error) return e.message;
56
+ return String(e);
57
+ }
58
+
59
+ /**
60
+ * Formats a duration in milliseconds as a compact human string
61
+ * (`3d 4h 12m`, `4h 12m`, `12m 5s`, `8s`).
62
+ *
63
+ * @param {number} ms
64
+ * @returns {string}
65
+ */
66
+ export function formatDuration(ms) {
67
+ if (!Number.isFinite(ms) || ms < 0) return "0s";
68
+ const s = Math.floor(ms / 1000);
69
+ const parts = [
70
+ { unit: "d", n: Math.floor(s / 86400) },
71
+ { unit: "h", n: Math.floor((s % 86400) / 3600) },
72
+ { unit: "m", n: Math.floor((s % 3600) / 60) },
73
+ { unit: "s", n: s % 60 },
74
+ ];
75
+ const nonzero = parts.filter((p) => p.n > 0);
76
+ const kept = nonzero.slice(0, 2);
77
+ if (kept.length === 0) return "0s";
78
+ return kept.map((p) => `${p.n}${p.unit}`).join(" ");
79
+ }
80
+
81
+ /**
82
+ * Formats a token/cost tally as returned by the `get_session_stats` RPC.
83
+ *
84
+ * @param {{tokens?: {total?: number|null}, cost?: number|null, contextUsage?: {percent?: number|null}|null}} stats
85
+ * @returns {string}
86
+ */
87
+ export function formatTokens(stats) {
88
+ const tokens = stats?.tokens?.total;
89
+ const cost = stats?.cost;
90
+ const percent = stats?.contextUsage?.percent;
91
+ const bits = [];
92
+ if (typeof tokens === "number")
93
+ bits.push(`${tokens.toLocaleString()} tokens`);
94
+ if (typeof cost === "number") bits.push(`$${cost.toFixed(2)}`);
95
+ if (typeof percent === "number") bits.push(`context ${Math.round(percent)}%`);
96
+ return bits.length > 0 ? bits.join(", ") : "no usage recorded";
97
+ }