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.
- package/dist/client/assets/index-AWIKUHJ_.css +1 -0
- package/dist/client/assets/{index-C65mFCh7.js → index-DmHLFNTs.js} +59 -59
- package/dist/client/index.html +2 -2
- package/dist/server/activity-log.js +2 -0
- package/dist/server/documents.js +97 -3
- package/dist/server/index.js +135 -3
- package/dist/server/mcp.js +185 -56
- package/dist/server/peek-outline.js +370 -0
- package/dist/server/state.js +117 -0
- package/dist/server/title-from-body.js +125 -0
- package/dist/server/workspaces.js +23 -0
- package/dist/server/ws.js +136 -6
- package/package.json +1 -1
- package/skill/SKILL.md +89 -41
- package/skill/agents/openwriter-enrichment-minion.md +7 -0
- package/skill/docs/setup.md +62 -0
- package/dist/client/assets/index-Ch3Z898_.css +0 -1
|
@@ -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
|
+
}
|
package/dist/server/state.js
CHANGED
|
@@ -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 {
|