@brianmichel/pi-noodle 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.
Files changed (43) hide show
  1. package/LICENSE +21 -0
  2. package/README.md +231 -0
  3. package/index.ts +1 -0
  4. package/package.json +70 -0
  5. package/src/AGENTS.md +33 -0
  6. package/src/commands/index.ts +51 -0
  7. package/src/commands/memory-crud.ts +136 -0
  8. package/src/commands/review.ts +291 -0
  9. package/src/commands/setup.ts +189 -0
  10. package/src/commands/status.ts +32 -0
  11. package/src/commands/ui.ts +14 -0
  12. package/src/commands/web.ts +40 -0
  13. package/src/commands.ts +1 -0
  14. package/src/config/schema.ts +234 -0
  15. package/src/config-screen.ts +439 -0
  16. package/src/config.ts +159 -0
  17. package/src/constants.ts +1 -0
  18. package/src/debug-overlay.ts +230 -0
  19. package/src/extension.ts +166 -0
  20. package/src/index.ts +1 -0
  21. package/src/memory/backend.ts +22 -0
  22. package/src/memory/embedder.ts +7 -0
  23. package/src/memory/embedders/lm-studio.ts +25 -0
  24. package/src/memory/embedders/openai.ts +66 -0
  25. package/src/memory/extractor.ts +189 -0
  26. package/src/memory/policy.ts +325 -0
  27. package/src/memory/project-identity.ts +51 -0
  28. package/src/memory/runtime.ts +70 -0
  29. package/src/memory/service.ts +761 -0
  30. package/src/memory/turso-backend.ts +716 -0
  31. package/src/memory/types.ts +192 -0
  32. package/src/notifications.ts +11 -0
  33. package/src/queue.ts +42 -0
  34. package/src/session.ts +72 -0
  35. package/src/tools.ts +172 -0
  36. package/src/types.ts +81 -0
  37. package/src/utils.ts +68 -0
  38. package/src/web/dev.ts +7 -0
  39. package/src/web/index.html +1963 -0
  40. package/src/web/manager.ts +92 -0
  41. package/src/web/run.ts +33 -0
  42. package/src/web/server.ts +212 -0
  43. package/tsconfig.json +17 -0
package/LICENSE ADDED
@@ -0,0 +1,21 @@
1
+ MIT License
2
+
3
+ Copyright (c) 2026 Brian Michel
4
+
5
+ Permission is hereby granted, free of charge, to any person obtaining a copy
6
+ of this software and associated documentation files (the "Software"), to deal
7
+ in the Software without restriction, including without limitation the rights
8
+ to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
9
+ copies of the Software, and to permit persons to whom the Software is
10
+ furnished to do so, subject to the following conditions:
11
+
12
+ The above copyright notice and this permission notice shall be included in all
13
+ copies or substantial portions of the Software.
14
+
15
+ THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
16
+ IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
17
+ FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
18
+ AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
19
+ LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
20
+ OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
21
+ SOFTWARE.
package/README.md ADDED
@@ -0,0 +1,231 @@
1
+ # pi-noodle
2
+
3
+ Long-term memory for Pi.
4
+
5
+ This repo is building a small, opinionated memory system that tries to be:
6
+
7
+ - **useful** — retrieve facts that actually help future turns
8
+ - **safe** — avoid saving temporary or sensitive content
9
+ - **automatic** — capture durable signals from normal conversation
10
+ - **inspectable** — review what was saved, pending, or discarded
11
+
12
+ Under the hood it uses [libSQL](https://turso.tech/libsql) for storage and vector similarity search for retrieval.
13
+
14
+ ![](img/dashboard.jpeg)
15
+
16
+ ## Quick start
17
+
18
+ ```bash
19
+ # Install as a Pi extension
20
+ pi install @brianmichel/pi-noodle
21
+
22
+ # In Pi, configure interactively
23
+ /noodle settings
24
+ ```
25
+
26
+ The setup screen shows the full config on one page:
27
+ 1. Database mode — local file or Turso Cloud
28
+ 2. Embedding provider — OpenAI, LM Studio, Ollama, or custom
29
+ 3. Relevant fields update in place as you switch modes/providers
30
+ 4. Required fields are validated before save
31
+
32
+ Settings are saved to `~/.pi/noodle/config.json` — memories travel with you across all projects.
33
+
34
+ ## `/noodle` command
35
+
36
+ ```
37
+ /noodle Show current config (paths, endpoint, masked API key)
38
+ /noodle remember <text> Save a memory directly
39
+ /noodle forget <query> Find and delete a memory
40
+ /noodle edit <query> Find and update a memory
41
+ /noodle review Review recent auto-saved memories
42
+ /noodle settings Interactive single-screen configuration editor with validation
43
+ /noodle setup Alias for /noodle settings
44
+ /noodle init Create a default config file for manual editing
45
+ /noodle web Start the Memory Explorer (auto-stops when all tabs close)
46
+ /noodle web stop Stop the explorer immediately
47
+ /noodle web dev Dev mode — hot reload on save, use web stop when done
48
+ /noodle web 8080 Start on a custom port
49
+ ```
50
+
51
+ For UI development outside Pi, run `npm run web:dev` from the repo — it connects to your configured database and reloads the browser whenever you edit `src/web/index.html`.
52
+
53
+ ### Memory Explorer Web UI
54
+
55
+ Launch a dark-themed web interface to browse, search, and visualize your memories:
56
+
57
+ - **Live stats** — total memories, categories, scopes
58
+ - **Category filter** — dropdown of all stored categories
59
+ - **Text search** — substring matching on memory text
60
+ - **Dark mode** — GitHub-inspired color scheme
61
+
62
+ Run `/noodle web` in Pi to open the explorer in your browser. The server runs in a background process and **shuts down automatically ~2 seconds after you close all tabs**. Use `/noodle web stop` to kill it manually.
63
+
64
+ ## Config file
65
+
66
+ `~/.pi/noodle/config.json`:
67
+
68
+ ```json
69
+ {
70
+ "db": {
71
+ "mode": "local",
72
+ "path": "/Users/you/.pi/noodle/memories.db"
73
+ },
74
+ "embedding": {
75
+ "provider": "openai",
76
+ "apiKey": "sk-...",
77
+ "baseUrl": "https://api.openai.com/v1",
78
+ "model": "text-embedding-3-small"
79
+ }
80
+ }
81
+ ```
82
+
83
+ ### Cloud mode (Turso)
84
+
85
+ ```json
86
+ {
87
+ "db": {
88
+ "mode": "cloud",
89
+ "url": "libsql://my-db-org.turso.io",
90
+ "authToken": "eyJ..."
91
+ },
92
+ "embedding": {
93
+ "provider": "openai",
94
+ "apiKey": "sk-...",
95
+ "baseUrl": "https://api.openai.com/v1",
96
+ "model": "text-embedding-3-small"
97
+ },
98
+ "extractor": {
99
+ "mode": "balanced",
100
+ "model": "deepseek/deepseek-v4-flash:free",
101
+ "triggerEvery": 10
102
+ }
103
+ }
104
+ ```
105
+
106
+ ### Memory modes
107
+
108
+ When memory mode is not `off`, capture uses a **unified capture pipeline** with policy-gated persistence:
109
+
110
+ - `conservative`
111
+ - fewer extraction runs
112
+ - higher bar for auto-save
113
+ - softer inferences are usually discarded until reinforced
114
+ - `balanced` (recommended default)
115
+ - durable facts can auto-save
116
+ - medium-confidence preferences go to `/noodle review`
117
+ - `proactive`
118
+ - more frequent extraction
119
+ - more candidate discovery
120
+ - pending review queue grows faster, but saved-memory safety rules stay the same
121
+
122
+ The extractor model is configurable too, so you can tune quality/speed/cost separately from behavior mode.
123
+
124
+ ## Environment variable overrides
125
+
126
+ Env vars take priority over the config file:
127
+
128
+ | Variable | Overrides |
129
+ |---|---|
130
+ | `NOODLE_CONFIG_PATH` | Config file location |
131
+ | `NOODLE_DB_PATH` | Local DB path |
132
+ | `NOODLE_DB_URL` | Cloud DB URL |
133
+ | `NOODLE_DB_TOKEN` | Cloud DB auth token |
134
+ | `OPENAI_API_KEY` | Embedding API key |
135
+ | `EMBEDDING_BASE_URL` | Embedding endpoint URL |
136
+ | `EMBEDDING_MODEL` | Embedding model name |
137
+ | `NOODLE_EXTRACTOR_MODE` | Memory mode: off / conservative / balanced / proactive |
138
+ | `NOODLE_EXTRACTOR_MODEL` | Extractor model ID |
139
+ | `NOODLE_EXTRACTOR_TRIGGER_EVERY` | Automatic extraction cadence in user turns |
140
+ | `NOODLE_EXTRACTOR_DEBUG` | Show the extractor debug widget: true / false |
141
+
142
+ ## Architecture
143
+
144
+ ```text
145
+ Pi lifecycle events
146
+ └─► MemoryService.capture(event)
147
+ ├─► heuristic capture
148
+ ├─► optional LLM extraction
149
+ ├─► candidate promotion policy
150
+ └─► conversation capture / consolidation when needed
151
+
152
+
153
+ MemoryBackend
154
+
155
+ TursoBackend
156
+ ├─► libSQL (local or cloud)
157
+ └─► Embedder
158
+ ```
159
+
160
+ ### Capture pipeline
161
+
162
+ ```text
163
+ input / compact / switch / shutdown
164
+
165
+ └─► MemoryService.capture(event)
166
+
167
+ ├─► heuristic prefilter
168
+ │ ├─ blocks sensitive / temporary content
169
+ │ └─ catches explicit memory asks
170
+
171
+ ├─► optional LLM extraction
172
+ │ └─ turns conversation context into memory candidates
173
+
174
+ ├─► local promotion policy
175
+ │ ├─ save → durable memory DB
176
+ │ ├─ pending → /noodle review only
177
+ │ └─ discard → dropped
178
+
179
+ └─► retrieval injects only saved memories
180
+ ```
181
+
182
+ ### Why it is shaped this way
183
+
184
+ - Pi only tells the memory system **what event happened**
185
+ - `MemoryService` decides **which capture stages should run**
186
+ - heuristic and LLM candidates share the **same promotion path**
187
+ - pending memories stay out of retrieval until reviewed or reinforced
188
+
189
+ ### What gets stored
190
+
191
+ Every memory is a row in SQLite with `text`, `embedding` (F32_BLOB), `category`, `categories`, `scope` (userId/assistantId/sessionId), and arbitrary `metadata`.
192
+
193
+ ### Search
194
+
195
+ Vector similarity via `vector_distance_cos()` in libSQL, ranked by cosine distance, post-filtered by category and threshold.
196
+
197
+ ### Policy and review
198
+
199
+ The system intentionally separates:
200
+
201
+ - **detection** — heuristics and LLM extraction find memory candidates
202
+ - **promotion** — local policy decides save / pending / discard
203
+ - **retrieval** — only saved memories are injected into prompts
204
+
205
+ That keeps the system proactive without letting low-confidence guesses pollute retrieval. Pending candidates stay visible in `/noodle review` but are **not** injected until promoted.
206
+
207
+ ## File layout
208
+
209
+ ```
210
+ src/
211
+ ├── config.ts # Config resolution (~/.pi/noodle/config.json + env vars)
212
+ ├── config-screen.ts # Flat single-screen config editor for /noodle settings
213
+ ├── constants.ts # DEFAULT_AGENT_ID
214
+ ├── types.ts # NoodleConfig, JsonObject, NotificationTarget, etc.
215
+ ├── utils.ts # maskSecret, describeError, formatJson, extractTextContent
216
+ ├── commands.ts # /noodle command + interactive setup entrypoint
217
+ ├── extension.ts # Pi extension lifecycle hooks
218
+ ├── tools.ts # memory_add / search / list / get / update / delete
219
+ ├── session.ts # Session message collection
220
+ ├── queue.ts # Sequential async write queue
221
+ ├── notifications.ts # UI notification helpers
222
+ └── memory/
223
+ ├── backend.ts # MemoryBackend interface
224
+ ├── types.ts # MemoryRecord, MemoryScope, etc.
225
+ ├── turso-backend.ts # TursoBackend (libSQL + vector search)
226
+ ├── embedder.ts # Embedder type
227
+ ├── embedders/ # openai.ts, lm-studio.ts
228
+ ├── service.ts # MemoryService (event-driven capture pipeline + promotion)
229
+ ├── policy.ts # Heuristics (classification, repetition, retrieval)
230
+ └── runtime.ts # Wiring (config + TursoBackend + MemoryService)
231
+ ```
package/index.ts ADDED
@@ -0,0 +1 @@
1
+ export { default } from "./src/index.ts";
package/package.json ADDED
@@ -0,0 +1,70 @@
1
+ {
2
+ "name": "@brianmichel/pi-noodle",
3
+ "version": "0.1.0",
4
+ "description": "Long-term memory for Pi — local libSQL database with vector search.",
5
+ "type": "module",
6
+ "keywords": [
7
+ "pi",
8
+ "pi.dev",
9
+ "pi-package",
10
+ "pi-extension",
11
+ "pi-plugin",
12
+ "ai-agent",
13
+ "agent-memory",
14
+ "memory",
15
+ "libsql",
16
+ "turso",
17
+ "vector-search"
18
+ ],
19
+ "license": "MIT",
20
+ "repository": {
21
+ "type": "git",
22
+ "url": "git+https://github.com/brianmichel/pi-noodle.git"
23
+ },
24
+ "homepage": "https://github.com/brianmichel/pi-noodle#readme",
25
+ "bugs": {
26
+ "url": "https://github.com/brianmichel/pi-noodle/issues"
27
+ },
28
+ "publishConfig": {
29
+ "access": "public"
30
+ },
31
+ "engines": {
32
+ "node": ">=22"
33
+ },
34
+ "files": [
35
+ "index.ts",
36
+ "src",
37
+ "README.md",
38
+ "tsconfig.json"
39
+ ],
40
+ "scripts": {
41
+ "build": "npm run check",
42
+ "check": "tsc -p tsconfig.json --noEmit",
43
+ "clean": "echo 'nothing to clean'",
44
+ "test": "node --test --experimental-strip-types test/**/*.test.ts",
45
+ "web:dev": "bun run src/web/dev.ts"
46
+ },
47
+ "exports": {
48
+ ".": "./index.ts"
49
+ },
50
+ "pi": {
51
+ "extensions": [
52
+ "./src/index.ts"
53
+ ]
54
+ },
55
+ "dependencies": {
56
+ "@libsql/client": "^0.15.0"
57
+ },
58
+ "peerDependencies": {
59
+ "@earendil-works/pi-ai": "*",
60
+ "@earendil-works/pi-coding-agent": "*"
61
+ },
62
+ "devDependencies": {
63
+ "@earendil-works/pi-ai": "*",
64
+ "@earendil-works/pi-coding-agent": "*",
65
+ "@earendil-works/pi-tui": "^0.75.5",
66
+ "@types/bun": "^1.2.0",
67
+ "@types/node": "^24.10.1",
68
+ "typescript": "^5.9.3"
69
+ }
70
+ }
package/src/AGENTS.md ADDED
@@ -0,0 +1,33 @@
1
+ # AGENTS.md
2
+
3
+ This file applies to this entire repository.
4
+
5
+ ## Goal
6
+
7
+ We are trying to build a minimal, high-quality memory system for the Pi.dev agent harness. We aim to have a high quality test suite to help prove the efficacy of this project. It should be straight forward for memories to be stored, and retrieved. Ideally this happens automatically for the user.
8
+
9
+ ## Type safety
10
+
11
+ Prefer creating types where needed to represent things instead of just strings. We like types since they help us better understand and reason about our code. Use the TypeScript type system to make contracts clear, and ensure that side effects are easily dealt with.
12
+
13
+ ## Tooling
14
+
15
+ This project uses mise to provide a unified interface into dependencies and tasks. The following tasks are available:
16
+
17
+ - `mise check`: run the TypeScript type checker
18
+ - `mise install`: install any dependencies
19
+ - `mise precommit`: run precommit validation
20
+ - `mise test`: runs this project's unit test suite.
21
+
22
+ ## Dead Code & Comments
23
+
24
+ - Delete dead code. Do not deprecate it, alias it, or leave it behind "for consumers." This is a private monorepo, not a published library.
25
+ - When a refactor replaces an interface or flow, remove the superseded entrypoints in the same change. Do not keep compatibility wrappers, transitional fallbacks, or duplicate code paths unless the user explicitly asks for a staged migration.
26
+ - Update tests and callers to the new seam instead of preserving the old one.
27
+ - Do not add decorative section-divider comments (e.g. `// -----------`).
28
+ - Do not add comments that restate what the code already says.
29
+ - JSDoc on public package exports is expected.
30
+
31
+ ## Validation
32
+
33
+ You can validate your work by running the precommit task and ensure it has a normal exit status and there is no abnormal output.
@@ -0,0 +1,51 @@
1
+ import type { ExtensionAPI } from "@earendil-works/pi-coding-agent";
2
+
3
+ import { resolveConfigPath, writeConfig } from "../config.ts";
4
+ import { runEdit, runForget, runRemember } from "./memory-crud.ts";
5
+ import { runReview } from "./review.ts";
6
+ import { runSetup } from "./setup.ts";
7
+ import { runStatus } from "./status.ts";
8
+ import type { CtxUi } from "./ui.ts";
9
+ import { runWeb } from "./web.ts";
10
+
11
+ export function registerCommands(pi: ExtensionAPI): void {
12
+ pi.registerCommand("noodle", {
13
+ description: "Noodle memory — status, remember/forget/edit, review, and web explorer",
14
+ handler: async (args, ctx) => {
15
+ const sub = args.trim();
16
+ const ui = ctx.ui as unknown as CtxUi;
17
+
18
+ if (sub === "settings" || sub === "setup") {
19
+ await runSetup(ui);
20
+ return;
21
+ }
22
+ if (sub === "review") {
23
+ await runReview(ui);
24
+ return;
25
+ }
26
+ if (sub.startsWith("remember")) {
27
+ await runRemember(ui, sub.slice("remember".length).trim());
28
+ return;
29
+ }
30
+ if (sub.startsWith("forget")) {
31
+ await runForget(ui, sub.slice("forget".length).trim());
32
+ return;
33
+ }
34
+ if (sub.startsWith("edit")) {
35
+ await runEdit(ui, sub.slice("edit".length).trim());
36
+ return;
37
+ }
38
+ if (sub === "init") {
39
+ writeConfig({});
40
+ ctx.ui.notify(`Created config at ${resolveConfigPath()}. Run /noodle settings to configure.`, "info");
41
+ return;
42
+ }
43
+ if (sub.startsWith("web")) {
44
+ await runWeb(ui, sub);
45
+ return;
46
+ }
47
+
48
+ runStatus(ui);
49
+ },
50
+ });
51
+ }
@@ -0,0 +1,136 @@
1
+ import { memoryService } from "../memory/runtime.ts";
2
+ import type { MemoryRecord } from "../memory/types.ts";
3
+ import { describeError } from "../utils.ts";
4
+ import type { CtxUi } from "./ui.ts";
5
+
6
+ export async function runRemember(ui: CtxUi, initialText: string): Promise<void> {
7
+ try {
8
+ const text = (initialText || await ui.input("Memory to save", "") || "").trim();
9
+ if (!text) {
10
+ ui.notify("Nothing saved — memory text is required.", "info");
11
+ return;
12
+ }
13
+
14
+ await memoryService.add({
15
+ text,
16
+ metadata: {
17
+ source: "manual_command",
18
+ auto_saved: false,
19
+ },
20
+ });
21
+ ui.notify(`Saved memory: ${summarizeMemory(text)}`, "info");
22
+ } catch (error) {
23
+ ui.notify(`Remember failed: ${describeError(error)}`, "error");
24
+ }
25
+ }
26
+
27
+ export async function runForget(ui: CtxUi, queryText: string): Promise<void> {
28
+ try {
29
+ const query = (queryText || await ui.input("Find memory to forget", "") || "").trim();
30
+ if (!query) {
31
+ ui.notify("Forget cancelled — enter a memory query.", "info");
32
+ return;
33
+ }
34
+
35
+ const target = await pickMemoryForAction(ui, query, "delete");
36
+ if (!target?.id) return;
37
+
38
+ const ok = await ui.confirm("Delete this memory?", target.text);
39
+ if (!ok) {
40
+ ui.notify("Forget cancelled.", "info");
41
+ return;
42
+ }
43
+
44
+ await memoryService.delete(target.id);
45
+ ui.notify(`Deleted memory: ${summarizeMemory(target.text)}`, "info");
46
+ } catch (error) {
47
+ ui.notify(`Forget failed: ${describeError(error)}`, "error");
48
+ }
49
+ }
50
+
51
+ export async function runEdit(ui: CtxUi, queryText: string): Promise<void> {
52
+ try {
53
+ const query = (queryText || await ui.input("Find memory to edit", "") || "").trim();
54
+ if (!query) {
55
+ ui.notify("Edit cancelled — enter a memory query.", "info");
56
+ return;
57
+ }
58
+
59
+ const target = await pickMemoryForAction(ui, query, "edit");
60
+ if (!target?.id) return;
61
+
62
+ const replacement = (await ui.input("Replacement text", target.text) || "").trim();
63
+ if (!replacement) {
64
+ ui.notify("Edit cancelled — replacement text is required.", "info");
65
+ return;
66
+ }
67
+ if (replacement === target.text) {
68
+ ui.notify("No changes made.", "info");
69
+ return;
70
+ }
71
+
72
+ await memoryService.update(target.id, {
73
+ text: replacement,
74
+ metadata: {
75
+ ...target.metadata,
76
+ source: "manual_edit",
77
+ updated_from: target.text,
78
+ },
79
+ });
80
+ ui.notify(`Updated memory: ${summarizeMemory(replacement)}`, "info");
81
+ } catch (error) {
82
+ ui.notify(`Edit failed: ${describeError(error)}`, "error");
83
+ }
84
+ }
85
+
86
+ async function pickMemoryForAction(
87
+ ui: CtxUi,
88
+ query: string,
89
+ action: "edit" | "delete",
90
+ ): Promise<MemoryRecord | null> {
91
+ const matches = await findMemoryMatches(query);
92
+ if (matches.length === 0) {
93
+ ui.notify(`No memories matched: ${query}`, "info");
94
+ return null;
95
+ }
96
+ if (matches.length === 1) {
97
+ return matches[0] ?? null;
98
+ }
99
+
100
+ ui.notify(`Top matches for ${action}:`, "info");
101
+ for (let index = 0; index < matches.length; index += 1) {
102
+ ui.notify(`[${index + 1}] ${summarizeMemory(matches[index]!.text)}`, "info");
103
+ }
104
+
105
+ const raw = (await ui.input(`Choose memory to ${action} (1-${matches.length})`, "1") || "").trim();
106
+ const index = parseInt(raw, 10) - 1;
107
+ if (Number.isNaN(index) || index < 0 || index >= matches.length) {
108
+ ui.notify(`Invalid selection — cancelled ${action}.`, "info");
109
+ return null;
110
+ }
111
+
112
+ return matches[index] ?? null;
113
+ }
114
+
115
+ async function findMemoryMatches(query: string): Promise<MemoryRecord[]> {
116
+ const normalized = query.trim().toLowerCase();
117
+ if (!normalized) return [];
118
+
119
+ const semantic = await memoryService.search({ query, limit: 8 }).catch(() => []);
120
+ const listed = await memoryService.list();
121
+ const substring = listed.filter((memory) => memory.text.toLowerCase().includes(normalized));
122
+
123
+ const deduped = new Map<string, MemoryRecord>();
124
+ for (const memory of [...semantic, ...substring]) {
125
+ const key = memory.id ?? memory.text;
126
+ if (!deduped.has(key)) deduped.set(key, memory);
127
+ }
128
+
129
+ return Array.from(deduped.values()).slice(0, 8);
130
+ }
131
+
132
+ function summarizeMemory(text: string, max = 80): string {
133
+ const singleLine = text.replace(/\s+/g, " ").trim();
134
+ if (singleLine.length <= max) return singleLine;
135
+ return `${singleLine.slice(0, Math.max(0, max - 1))}…`;
136
+ }