openwriter 0.23.0 → 0.25.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.
@@ -0,0 +1,370 @@
1
+ /**
2
+ * Outline + peek primitives for node-level doc navigation.
3
+ *
4
+ * The "orient by content, pick by node" architectural rule lives here.
5
+ *
6
+ * - outline returns the heading skeleton (and optional drill-down into one
7
+ * section) so an agent can see what a doc IS without reading bodies.
8
+ * - peek returns a windowed slice of nodes (around an anchor, by range, by
9
+ * position, by explicit IDs) once the agent has a node ID to orient on.
10
+ *
11
+ * Neither tool initiates by node — node IDs are byproducts of content
12
+ * orientation (search hit, outline pick, deep-link click, prior peek).
13
+ */
14
+ /** Render the outline for a doc.
15
+ *
16
+ * Default behavior: heading tree only (filtered by depth).
17
+ * underHeading set: full block list inside that section, with one-line
18
+ * previews per block (~10–15 tokens each).
19
+ * No headings present: falls back to top-level block previews.
20
+ */
21
+ export function outline(doc, opts = {}) {
22
+ const depth = Math.max(1, Math.min(6, opts.depth ?? 3));
23
+ const content = doc.content || [];
24
+ let lines;
25
+ if (opts.underHeading) {
26
+ lines = renderSection(content, opts.underHeading);
27
+ }
28
+ else {
29
+ const headings = collectHeadings(content, depth);
30
+ // In OpenWriter convention the first h1 IS the doc title (already
31
+ // surfaced by every other read tool — read_pad header, search_docs,
32
+ // browse_docs). Drop it from the outline so we don't waste a line
33
+ // restating what the caller already knows. Subsequent h1s (rare —
34
+ // would be a multi-chapter doc) are kept; they're real structure.
35
+ const filtered = headings.length > 0 && headings[0].level === 1
36
+ ? headings.slice(1)
37
+ : headings;
38
+ if (filtered.length > 0) {
39
+ lines = filtered.map(renderHeading);
40
+ }
41
+ else if (headings.length === 0) {
42
+ // Doc has no headings — fall back to block previews so the agent
43
+ // still gets a structural read.
44
+ lines = collectTopLevelPreviews(content);
45
+ }
46
+ else {
47
+ // Doc has ONLY the title h1 (no body headings). Fall back to
48
+ // top-level block previews so the agent has something to navigate.
49
+ lines = collectTopLevelPreviews(content);
50
+ }
51
+ }
52
+ // Pagination — applied to rendered lines so the agent can walk a
53
+ // huge skeleton incrementally without re-fetching the whole thing.
54
+ const offset = Math.max(0, opts.offset ?? 0);
55
+ const limit = Math.max(1, opts.limit ?? 200);
56
+ const total = lines.length;
57
+ const slice = lines.slice(offset, offset + limit);
58
+ const header = opts.underHeading
59
+ ? `outline (section ${opts.underHeading}):`
60
+ : `outline (depth ${depth}):`;
61
+ const footer = total > offset + limit
62
+ ? `\n[showing ${slice.length} of ${total} lines — call again with offset=${offset + limit} for more]`
63
+ : '';
64
+ return `${header}\n${slice.join('\n')}${footer}`;
65
+ }
66
+ function collectHeadings(content, maxDepth) {
67
+ const out = [];
68
+ function walk(nodes) {
69
+ for (const n of nodes) {
70
+ if (n.type === 'heading') {
71
+ const level = n.attrs?.level ?? 1;
72
+ if (level <= maxDepth) {
73
+ out.push({ level, text: extractText(n), id: n.attrs?.id });
74
+ }
75
+ }
76
+ // Headings can only legally appear at top level in OpenWriter docs,
77
+ // but recurse defensively so we don't miss any author-malformed trees.
78
+ if (n.content)
79
+ walk(n.content);
80
+ }
81
+ }
82
+ walk(content);
83
+ return out;
84
+ }
85
+ function renderHeading(h) {
86
+ const indent = ' '.repeat(Math.max(0, h.level - 1));
87
+ const idTag = h.id ? `[h${h.level}:${h.id}] ` : `[h${h.level}] `;
88
+ return `${indent}${idTag}${h.text}`;
89
+ }
90
+ /** Return preview lines for every top-level block in the doc — used when
91
+ * there are no headings to anchor an outline on. */
92
+ function collectTopLevelPreviews(content) {
93
+ return content.map((node) => previewLine(node));
94
+ }
95
+ /** Walk the doc and return preview lines for every block from the named
96
+ * heading up to (but not including) the next heading at the same level
97
+ * or shallower. */
98
+ function renderSection(content, headingId) {
99
+ // Find the heading at top level
100
+ let startIdx = -1;
101
+ let startLevel = 0;
102
+ for (let i = 0; i < content.length; i++) {
103
+ const n = content[i];
104
+ if (n.type === 'heading' && n.attrs?.id === headingId) {
105
+ startIdx = i;
106
+ startLevel = n.attrs?.level ?? 1;
107
+ break;
108
+ }
109
+ }
110
+ if (startIdx === -1)
111
+ return [`(no heading with id ${headingId} found at top level)`];
112
+ const lines = [];
113
+ // Include the heading itself first, then walk forward until end-of-section.
114
+ for (let i = startIdx; i < content.length; i++) {
115
+ const n = content[i];
116
+ if (i > startIdx && n.type === 'heading' && (n.attrs?.level ?? 1) <= startLevel)
117
+ break;
118
+ lines.push(previewLine(n));
119
+ }
120
+ return lines;
121
+ }
122
+ /** ~10–15 token preview of a top-level block — type tag + nodeId + first
123
+ * ~80 chars of inner text. Lists collapse to their item-count summary. */
124
+ function previewLine(node) {
125
+ const id = node.attrs?.id ? `:${node.attrs.id}` : '';
126
+ if (node.type === 'heading') {
127
+ const level = node.attrs?.level ?? 1;
128
+ return `[h${level}${id}] ${extractText(node)}`;
129
+ }
130
+ if (node.type === 'paragraph') {
131
+ const txt = extractText(node);
132
+ return `[p${id}] ${truncate(txt, 80)}`;
133
+ }
134
+ if (node.type === 'bulletList' || node.type === 'orderedList' || node.type === 'taskList') {
135
+ const count = (node.content ?? []).length;
136
+ const shortType = node.type === 'bulletList' ? 'ul' : node.type === 'orderedList' ? 'ol' : 'tl';
137
+ return `[${shortType}${id}] (${count} item${count === 1 ? '' : 's'})`;
138
+ }
139
+ if (node.type === 'blockquote') {
140
+ return `[bq${id}] ${truncate(firstChildText(node), 80)}`;
141
+ }
142
+ if (node.type === 'codeBlock') {
143
+ const lang = node.attrs?.language || '';
144
+ return `[code${id}${lang ? ' ' + lang : ''}] ${truncate(extractText(node), 60)}`;
145
+ }
146
+ if (node.type === 'horizontalRule') {
147
+ return `[hr${id}] ---`;
148
+ }
149
+ if (node.type === 'table') {
150
+ const rows = (node.content ?? []).length;
151
+ return `[table${id}] (${rows} row${rows === 1 ? '' : 's'})`;
152
+ }
153
+ if (node.type === 'image') {
154
+ return `[img${id}] ${node.attrs?.alt || node.attrs?.src || ''}`;
155
+ }
156
+ if (node.type === 'footnoteSection' || node.type === 'footnoteDefinition') {
157
+ return `[${node.type}${id}] ${truncate(firstChildText(node), 60)}`;
158
+ }
159
+ return `[${node.type}${id}]`;
160
+ }
161
+ function firstChildText(node) {
162
+ if (!node.content)
163
+ return '';
164
+ for (const c of node.content) {
165
+ const t = extractText(c);
166
+ if (t)
167
+ return t;
168
+ }
169
+ return '';
170
+ }
171
+ function extractText(node) {
172
+ if (node.type === 'text' && typeof node.text === 'string')
173
+ return node.text;
174
+ if (!node.content)
175
+ return '';
176
+ return node.content.map(extractText).join('');
177
+ }
178
+ function truncate(s, max) {
179
+ if (s.length <= max)
180
+ return s;
181
+ return s.slice(0, max).trimEnd() + '…';
182
+ }
183
+ /** Return a windowed slice of TipTap nodes from a doc per the target spec.
184
+ * The caller renders via compactNodes — this function returns raw nodes
185
+ * so the rendering pipeline stays uniform with read_pad / get_nodes. */
186
+ export function peek(doc, target) {
187
+ const content = doc.content || [];
188
+ if ('node' in target) {
189
+ return findById(content, [target.node]);
190
+ }
191
+ if ('nodes' in target) {
192
+ return findById(content, target.nodes);
193
+ }
194
+ if ('around' in target) {
195
+ const idx = findTopLevelIndex(content, target.around);
196
+ if (idx === -1)
197
+ return [];
198
+ const before = Math.max(0, target.before ?? 1);
199
+ const after = Math.max(0, target.after ?? 1);
200
+ return content.slice(Math.max(0, idx - before), Math.min(content.length, idx + after + 1));
201
+ }
202
+ if ('from' in target) {
203
+ const a = findTopLevelIndex(content, target.from);
204
+ const b = findTopLevelIndex(content, target.to);
205
+ if (a === -1 || b === -1)
206
+ return [];
207
+ const [lo, hi] = a <= b ? [a, b] : [b, a];
208
+ return content.slice(lo, hi + 1);
209
+ }
210
+ if ('first' in target) {
211
+ return content.slice(0, Math.max(1, target.first));
212
+ }
213
+ if ('last' in target) {
214
+ const n = Math.max(1, target.last);
215
+ return content.slice(Math.max(0, content.length - n));
216
+ }
217
+ if ('position' in target) {
218
+ const pos = Math.max(0, Math.min(1, target.position));
219
+ const span = Math.max(1, target.span ?? 3);
220
+ const idx = Math.min(content.length - 1, Math.floor(content.length * pos));
221
+ return content.slice(idx, Math.min(content.length, idx + span));
222
+ }
223
+ return [];
224
+ }
225
+ /** Find every node matching one of the given IDs — full-tree walk to support
226
+ * IDs on nested blocks (list items, table cells, etc.). Same shape as
227
+ * state.findNodesByIds but lives here to keep peek-outline self-contained. */
228
+ function findById(content, ids) {
229
+ const set = new Set(ids);
230
+ const out = [];
231
+ function walk(nodes) {
232
+ for (const n of nodes) {
233
+ if (n.attrs?.id && set.has(n.attrs.id))
234
+ out.push(n);
235
+ if (n.content)
236
+ walk(n.content);
237
+ }
238
+ }
239
+ walk(content);
240
+ return out;
241
+ }
242
+ /** Return the top-level doc.content index whose subtree contains nodeId.
243
+ * Handles nested IDs by walking each top-level subtree. */
244
+ function findTopLevelIndex(content, id) {
245
+ function contains(node) {
246
+ if (node.attrs?.id === id)
247
+ return true;
248
+ if (!node.content)
249
+ return false;
250
+ for (const c of node.content)
251
+ if (contains(c))
252
+ return true;
253
+ return false;
254
+ }
255
+ for (let i = 0; i < content.length; i++) {
256
+ if (contains(content[i]))
257
+ return i;
258
+ }
259
+ return -1;
260
+ }
261
+ /** Count whitespace-delimited tokens across every text node in `nodes`.
262
+ * Mirrors the convention used elsewhere in the server (extractText + split). */
263
+ export function countWords(nodes) {
264
+ function collect(node) {
265
+ if (node.type === 'text' && typeof node.text === 'string')
266
+ return node.text;
267
+ if (!node.content)
268
+ return '';
269
+ return node.content.map(collect).join(' ');
270
+ }
271
+ const text = nodes.map(collect).join(' ').trim();
272
+ if (!text)
273
+ return 0;
274
+ return text.split(/\s+/).length;
275
+ }
276
+ /** Truncate a doc at a top-level node boundary so the returned content
277
+ * doesn't exceed `maxWords`. Returns the original doc unchanged if it
278
+ * already fits. Always includes at least one top-level node so callers
279
+ * never receive an empty body.
280
+ *
281
+ * read_pad's contract: a fixed-window read, not a full-body read. Above
282
+ * the cap, the agent gets the doc opening (most context-rich slice —
283
+ * title, intro, first few sections) plus the lastNodeId so `peek_doc`
284
+ * can continue from the boundary, or `outline_doc` / `search_docs` can
285
+ * jump elsewhere.
286
+ *
287
+ * Node-boundary truncation (never splits a top-level block) keeps the
288
+ * returned slice structurally valid markdown — list items, blockquotes,
289
+ * and code blocks stay intact. */
290
+ export function truncateRead(doc, maxWords) {
291
+ const content = doc.content || [];
292
+ const totalWords = countWords(content);
293
+ const lastTopId = (n) => n?.attrs?.id ?? null;
294
+ if (totalWords <= maxWords) {
295
+ return {
296
+ doc,
297
+ truncated: false,
298
+ totalWords,
299
+ returnedWords: totalWords,
300
+ lastNodeId: lastTopId(content[content.length - 1]),
301
+ remaining: 0,
302
+ };
303
+ }
304
+ const included = [];
305
+ let words = 0;
306
+ let lastNodeId = null;
307
+ for (const n of content) {
308
+ const w = countWords([n]);
309
+ // Always include at least one node — even if it alone exceeds the cap,
310
+ // an empty body would be a worse failure mode than a slightly oversize one.
311
+ if (included.length > 0 && words + w > maxWords)
312
+ break;
313
+ included.push(n);
314
+ words += w;
315
+ if (n.attrs?.id)
316
+ lastNodeId = n.attrs.id;
317
+ }
318
+ return {
319
+ doc: { ...doc, content: included },
320
+ truncated: true,
321
+ totalWords,
322
+ returnedWords: words,
323
+ lastNodeId,
324
+ remaining: totalWords - words,
325
+ };
326
+ }
327
+ /** Find blocks whose text matches the query inside one doc. Returns up to
328
+ * `limit` matches with the matched node's ID, type, and a snippet around
329
+ * the hit. Case-insensitive substring match, same shape as the workspace-
330
+ * scoped search but scoped to one doc and returning node-level handles.
331
+ *
332
+ * Only nodes with an addressable `attrs.id` are emitted as matches. The
333
+ * walker still descends into IDless children (text runs, inline marks) so
334
+ * block-level matches are found, but it does NOT emit those children as
335
+ * their own hits — they're already represented by their parent block's
336
+ * entry, and emitting them duplicates results. */
337
+ export function searchInDoc(doc, query, limit = 10) {
338
+ const q = query.trim().toLowerCase();
339
+ if (!q)
340
+ return [];
341
+ const out = [];
342
+ function walk(nodes) {
343
+ if (out.length >= limit)
344
+ return;
345
+ for (const n of nodes) {
346
+ if (out.length >= limit)
347
+ return;
348
+ if (n.attrs?.id) {
349
+ const text = extractText(n);
350
+ if (text) {
351
+ const lower = text.toLowerCase();
352
+ const idx = lower.indexOf(q);
353
+ if (idx !== -1) {
354
+ const start = Math.max(0, idx - 30);
355
+ const end = Math.min(text.length, idx + q.length + 50);
356
+ out.push({
357
+ nodeId: n.attrs.id,
358
+ type: n.type,
359
+ snippet: (start > 0 ? '…' : '') + text.slice(start, end) + (end < text.length ? '…' : ''),
360
+ });
361
+ }
362
+ }
363
+ }
364
+ if (n.content)
365
+ walk(n.content);
366
+ }
367
+ }
368
+ walk(doc.content || []);
369
+ return out;
370
+ }
@@ -19,6 +19,7 @@ import { markdownToNodes, resolvePreviousNodes, resolveGraveyard } from './markd
19
19
  import { extractOverlay, applyOverlayPure, splitMergedDoc, saveOverlay, loadOverlay, deleteOverlay, repairOverlaysOnStartup, diagLog } from './pending-overlay.js';
20
20
  import { harvestSentenceHashes, harvestCharCount, isEnrichmentStale } from './enrichment.js';
21
21
  import { clearActivityBuffer } from './activity-log.js';
22
+ import { titleFromDoc, shouldAutoTitle } from './title-from-body.js';
22
23
  /** Read the persisted identity graph (nodes + graveyard) from a file's
23
24
  * frontmatter. The save-time matcher reads previousNodes + graveyard
24
25
  * directly from disk every write — the disk is the source of truth, not
@@ -195,6 +196,21 @@ export function onExternalWriteConflict(listener) {
195
196
  externalWriteConflictListeners.add(listener);
196
197
  return () => externalWriteConflictListeners.delete(listener);
197
198
  }
199
+ const autoTitleAppliedListeners = new Set();
200
+ export function onAutoTitleApplied(listener) {
201
+ autoTitleAppliedListeners.add(listener);
202
+ return () => autoTitleAppliedListeners.delete(listener);
203
+ }
204
+ function notifyAutoTitleApplied(newTitle) {
205
+ for (const listener of autoTitleAppliedListeners) {
206
+ try {
207
+ listener(newTitle);
208
+ }
209
+ catch (err) {
210
+ console.error('[State] auto-title listener threw:', err);
211
+ }
212
+ }
213
+ }
198
214
  function notifyExternalWriteConflict(filePath, diskMtime, loadedMtime) {
199
215
  for (const listener of externalWriteConflictListeners) {
200
216
  try {
@@ -2240,6 +2256,26 @@ function writeToDisk() {
2240
2256
  }
2241
2257
  }
2242
2258
  export function save() {
2259
+ // Auto-title from body content if the title is still default/empty.
2260
+ // Runs BEFORE filePath assignment so a brand-new doc lands at its
2261
+ // derived-title filename directly (no temp-file detour). For already-
2262
+ // saved temp files, the listener (ws.ts) calls promoteTempFile to
2263
+ // rename on disk. External docs are skipped — we never rename files
2264
+ // the user manages outside the openwriter data dir.
2265
+ //
2266
+ // `bumpDocVersion()` is required to defeat writeToDisk's no-op gate
2267
+ // when only the title changed (no body mutation between saves). Without
2268
+ // it, the title update would live in memory only and never reach disk.
2269
+ if (!isExternalDoc(state.filePath ?? '') && shouldAutoTitle(state.title)) {
2270
+ const derived = titleFromDoc(state.document);
2271
+ if (derived && derived !== state.title) {
2272
+ state.title = derived;
2273
+ if (state.metadata)
2274
+ state.metadata.title = derived;
2275
+ bumpDocVersion();
2276
+ notifyAutoTitleApplied(derived);
2277
+ }
2278
+ }
2243
2279
  if (!state.filePath) {
2244
2280
  // First save — assign a file path. Canonicalize at this identity
2245
2281
  // boundary so cache lookups and watcher subscriptions key on the
@@ -2655,6 +2691,87 @@ export function setAutoAcceptOnFile(filename, enabled) {
2655
2691
  }
2656
2692
  catch { /* best-effort */ }
2657
2693
  }
2694
+ /** Write a sortRequest marker onto a file. Stamps requestedAt; the agent
2695
+ * picks the doc up via list_pending_sorts and writes a proposal back. */
2696
+ export function setSortRequestOnFile(filename) {
2697
+ const targetPath = resolveDocPath(filename);
2698
+ if (!existsSync(targetPath))
2699
+ return;
2700
+ try {
2701
+ const raw = readFileSync(targetPath, 'utf-8');
2702
+ const parsed = markdownToTiptap(raw);
2703
+ parsed.metadata.sortRequest = { requestedAt: new Date().toISOString() };
2704
+ let markdown;
2705
+ if (isExternalDoc(targetPath)) {
2706
+ const body = tiptapToBody(parsed.document);
2707
+ markdown = parsed.rawFrontmatter
2708
+ ? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
2709
+ : body;
2710
+ }
2711
+ else {
2712
+ markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
2713
+ }
2714
+ atomicWriteFileSync(targetPath, markdown);
2715
+ invalidateDocCache(targetPath);
2716
+ }
2717
+ catch { /* best-effort */ }
2718
+ }
2719
+ /** Clear sortRequest and stamp lastSortedAt. Used on fulfillment (accept
2720
+ * or reject) — the marker retires the same way enrichmentStale retires
2721
+ * to lastEnrichedAt. */
2722
+ export function clearSortRequestOnFile(filename) {
2723
+ const targetPath = resolveDocPath(filename);
2724
+ if (!existsSync(targetPath))
2725
+ return;
2726
+ try {
2727
+ const raw = readFileSync(targetPath, 'utf-8');
2728
+ const parsed = markdownToTiptap(raw);
2729
+ delete parsed.metadata.sortRequest;
2730
+ parsed.metadata.lastSortedAt = new Date().toISOString();
2731
+ let markdown;
2732
+ if (isExternalDoc(targetPath)) {
2733
+ const body = tiptapToBody(parsed.document);
2734
+ markdown = parsed.rawFrontmatter
2735
+ ? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
2736
+ : body;
2737
+ }
2738
+ else {
2739
+ markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
2740
+ }
2741
+ atomicWriteFileSync(targetPath, markdown);
2742
+ invalidateDocCache(targetPath);
2743
+ }
2744
+ catch { /* best-effort */ }
2745
+ }
2746
+ /** Stamp a proposal onto an existing sortRequest. Used by the agent after
2747
+ * it has picked a destination — the UI flips the badge to "proposal ready"
2748
+ * and the user accepts/rejects via the in-menu popover. */
2749
+ export function setSortProposalOnFile(filename, proposal) {
2750
+ const targetPath = resolveDocPath(filename);
2751
+ if (!existsSync(targetPath))
2752
+ return;
2753
+ try {
2754
+ const raw = readFileSync(targetPath, 'utf-8');
2755
+ const parsed = markdownToTiptap(raw);
2756
+ const existing = parsed.metadata.sortRequest;
2757
+ if (!existing || typeof existing !== 'object')
2758
+ return; // no request to attach to
2759
+ parsed.metadata.sortRequest = { ...existing, proposal };
2760
+ let markdown;
2761
+ if (isExternalDoc(targetPath)) {
2762
+ const body = tiptapToBody(parsed.document);
2763
+ markdown = parsed.rawFrontmatter
2764
+ ? `---\n${parsed.rawFrontmatter}\n---\n\n${body}`
2765
+ : body;
2766
+ }
2767
+ else {
2768
+ markdown = tiptapToMarkdown(parsed.document, parsed.title, parsed.metadata);
2769
+ }
2770
+ atomicWriteFileSync(targetPath, markdown);
2771
+ invalidateDocCache(targetPath);
2772
+ }
2773
+ catch { /* best-effort */ }
2774
+ }
2658
2775
  /**
2659
2776
  * Strip pending attrs from a specific file on disk (not the active document).
2660
2777
  *
@@ -0,0 +1,125 @@
1
+ /**
2
+ * Auto-derive a document title from its body content.
3
+ *
4
+ * Algorithm adapted from Joplin's `markdownUtils.titleFromBody`
5
+ * (https://github.com/laurent22/joplin, AGPL-3.0) — algorithm only, fresh
6
+ * implementation in this repo under MIT. Same approach Bear, iA Writer,
7
+ * Apple Notes, and Joplin all converge on: the first non-empty line of
8
+ * the body, stripped of formatting, becomes the title.
9
+ *
10
+ * Lock detection schema: implicit via `shouldAutoTitle()`. While the title
11
+ * is in the `DEFAULT_TITLES` set or empty, we own the title and re-derive
12
+ * on every save. The moment the user (or an agent via rename_item) sets
13
+ * the title to anything else, auto-naming stops permanently for that doc.
14
+ * No frontmatter flag needed — the title itself IS the state.
15
+ *
16
+ * Notes on the TipTap-native approach: we walk the document JSON tree
17
+ * directly instead of running regexes on serialized markdown. Marks like
18
+ * link/bold/italic/strike are transparent to text extraction (TipTap stores
19
+ * inner text on text nodes, not raw `[text](url)`/`**bold**` syntax). That
20
+ * eliminates an entire class of edge cases Joplin's regex chain handles
21
+ * defensively. We still strip leading punctuation as a safety net for
22
+ * users who type raw markdown into a fresh paragraph that hasn't been
23
+ * input-parsed.
24
+ */
25
+ const DEFAULT_TITLES = new Set(['Untitled', 'New Document', 'Article']);
26
+ /**
27
+ * Hard ceiling for an auto-derived title. Titles that fit a natural
28
+ * sentence ending below this cap will use the sentence boundary (clean,
29
+ * no ellipsis). Titles whose first sentence runs past the cap get
30
+ * truncated at the nearest word boundary inside the limit. Tuned to
31
+ * what reads cleanly in a sidebar row — Bear and iA Writer's effective
32
+ * display widths are in this neighborhood. Joplin's 80 was usable but
33
+ * visually heavy; 60 reads tighter without losing useful information.
34
+ */
35
+ const TITLE_MAX_LENGTH = 60;
36
+ /**
37
+ * Trim a string to a usable title length, preferring sentence ending
38
+ * inside the cap, falling back to word-boundary truncation.
39
+ */
40
+ function trimToTitleLength(text, maxLen) {
41
+ // Prefer the first sentence boundary if it lands within the cap.
42
+ // Matches up to (but excluding) the punctuation, then whitespace or end.
43
+ const sentenceMatch = text.match(/^([^.!?]+)[.!?](?:\s|$)/);
44
+ if (sentenceMatch && sentenceMatch[1].trim().length <= maxLen) {
45
+ return sentenceMatch[1].trim();
46
+ }
47
+ if (text.length <= maxLen)
48
+ return text;
49
+ // Hard truncate. Prefer the last word boundary in the last 30% of the
50
+ // window so we don't chop mid-word when a clean break is close at hand.
51
+ const trunc = text.substring(0, maxLen);
52
+ const lastSpace = trunc.lastIndexOf(' ');
53
+ if (lastSpace > maxLen * 0.7) {
54
+ return trunc.substring(0, lastSpace);
55
+ }
56
+ return trunc;
57
+ }
58
+ /** Block types we skip entirely — code is rarely a good title, and
59
+ * decorative elements have no useful text. */
60
+ const SKIP_BLOCK_TYPES = new Set([
61
+ 'codeBlock',
62
+ 'horizontalRule',
63
+ 'hardBreak',
64
+ 'image',
65
+ ]);
66
+ /** Recursively collect inline text from a node tree, skipping atom/leaf
67
+ * types we don't want in titles (images, code blocks, etc.). */
68
+ function extractText(node) {
69
+ if (!node)
70
+ return '';
71
+ if (SKIP_BLOCK_TYPES.has(node.type))
72
+ return '';
73
+ if (typeof node.text === 'string')
74
+ return node.text;
75
+ if (!node.content || !Array.isArray(node.content))
76
+ return '';
77
+ let out = '';
78
+ for (const child of node.content) {
79
+ out += extractText(child);
80
+ }
81
+ return out;
82
+ }
83
+ /** Strip leading markdown-syntax characters that survive when a user
84
+ * typed raw markdown into a paragraph that didn't get input-parsed
85
+ * (e.g. a fresh stub block). Also handles trailing setext-style
86
+ * underline noise that might cling to a heading line. */
87
+ function cleanMarkdownNoise(text) {
88
+ let out = text;
89
+ // Leading list/heading/quote/emphasis markers
90
+ out = out.replace(/^[\s#>*_~`+\-=]+/, '');
91
+ // Trailing setext underline residue or whitespace
92
+ out = out.replace(/[\s#>*_~`+\-=]+$/, '');
93
+ return out.trim();
94
+ }
95
+ /**
96
+ * Extract a title from a TipTap document. Returns an empty string if
97
+ * the document has no usable text. Caller decides whether to apply.
98
+ */
99
+ export function titleFromDoc(doc) {
100
+ if (!doc || !doc.content || !Array.isArray(doc.content))
101
+ return '';
102
+ for (const block of doc.content) {
103
+ const raw = extractText(block);
104
+ const trimmed = raw.trim();
105
+ if (!trimmed)
106
+ continue;
107
+ const cleaned = cleanMarkdownNoise(trimmed);
108
+ if (!cleaned)
109
+ continue;
110
+ return trimToTitleLength(cleaned, TITLE_MAX_LENGTH);
111
+ }
112
+ return '';
113
+ }
114
+ /**
115
+ * Whether the auto-titler should act on a document with this title. True
116
+ * when the title is empty or one of the system defaults — meaning the
117
+ * user (or agent) has not committed to a name yet, so we're free to
118
+ * derive one. Once `shouldAutoTitle` returns false, auto-naming stops
119
+ * permanently for that doc.
120
+ */
121
+ export function shouldAutoTitle(title) {
122
+ if (!title)
123
+ return true;
124
+ return DEFAULT_TITLES.has(title);
125
+ }
@@ -537,6 +537,29 @@ export function setContainerAutoAccept(wsFile, containerId, enabled) {
537
537
  delete found.node.autoAccept;
538
538
  writeWorkspace(wsFile, ws);
539
539
  }
540
+ /** Set or clear the user-authored `purpose:` hint on a workspace. Trim and
541
+ * treat empty string as clear, so a user can blank the field in the UI. */
542
+ export function setWorkspacePurpose(wsFile, purpose) {
543
+ const ws = readWorkspace(wsFile);
544
+ const trimmed = purpose.trim();
545
+ if (trimmed)
546
+ ws.purpose = trimmed;
547
+ else
548
+ delete ws.purpose;
549
+ writeWorkspace(wsFile, ws);
550
+ }
551
+ export function setContainerPurpose(wsFile, containerId, purpose) {
552
+ const ws = readWorkspace(wsFile);
553
+ const found = findContainer(ws.root, containerId);
554
+ if (!found)
555
+ throw new Error(`Container ${containerId} not found in ${wsFile}`);
556
+ const trimmed = purpose.trim();
557
+ if (trimmed)
558
+ found.node.purpose = trimmed;
559
+ else
560
+ delete found.node.purpose;
561
+ writeWorkspace(wsFile, ws);
562
+ }
540
563
  /** Collect every file inside a workspace or container subtree. Used for broadcast. */
541
564
  export function collectFilesInWorkspace(wsFile) {
542
565
  try {