openwriter 0.19.0 → 0.20.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-BZ7LCzrR.js → index-B1-K-j46.js} +52 -52
- package/dist/client/index.html +1 -1
- package/dist/server/backlinks.js +148 -108
- package/dist/server/index.js +30 -5
- package/dist/server/mcp.js +75 -109
- package/dist/server/state.js +51 -17
- package/package.json +1 -1
package/dist/client/index.html
CHANGED
|
@@ -10,7 +10,7 @@
|
|
|
10
10
|
<link rel="preconnect" href="https://fonts.googleapis.com" />
|
|
11
11
|
<link rel="preconnect" href="https://fonts.gstatic.com" crossorigin />
|
|
12
12
|
<link href="https://fonts.googleapis.com/css2?family=Charter:ital,wght@0,400;0,700;1,400&family=Crimson+Pro:ital,wght@0,300;0,400;0,600;0,700;1,400&family=DM+Sans:ital,wght@0,400;0,500;0,600;0,700;1,400&family=DM+Serif+Display&family=IBM+Plex+Mono:wght@400;500;600&family=IBM+Plex+Sans:wght@400;500;600&family=Inter:wght@400;500;600;700&family=Libre+Baskerville:ital,wght@0,400;0,700;1,400&family=Literata:ital,opsz,wght@0,7..72,400;0,7..72,600;0,7..72,700;1,7..72,400&family=Newsreader:ital,opsz,wght@0,6..72,400;0,6..72,600;1,6..72,400&family=Playfair+Display:wght@400;600;700;900&family=Source+Serif+4:ital,opsz,wght@0,8..60,400;0,8..60,600;0,8..60,700;1,8..60,400&family=Space+Grotesk:wght@400;500;600;700&family=Space+Mono:wght@400;700&display=swap" rel="stylesheet" />
|
|
13
|
-
<script type="module" crossorigin src="/assets/index-
|
|
13
|
+
<script type="module" crossorigin src="/assets/index-B1-K-j46.js"></script>
|
|
14
14
|
<link rel="stylesheet" crossorigin href="/assets/index-0ttVnjRp.css">
|
|
15
15
|
</head>
|
|
16
16
|
<body>
|
package/dist/server/backlinks.js
CHANGED
|
@@ -1,26 +1,28 @@
|
|
|
1
1
|
/**
|
|
2
|
-
*
|
|
3
|
-
*
|
|
2
|
+
* Connections engine (v0.20.0): doc-to-doc connections are structural data,
|
|
3
|
+
* stored as `references: [docId, ...]` arrays in each source's frontmatter.
|
|
4
|
+
* The inbound list on any target is computed live as the inverse of every
|
|
5
|
+
* doc's references — there is no stored derived field on disk.
|
|
4
6
|
*
|
|
5
7
|
* Design:
|
|
6
|
-
* -
|
|
7
|
-
* - Backlinks
|
|
8
|
-
*
|
|
9
|
-
*
|
|
10
|
-
* -
|
|
8
|
+
* - `references:` in frontmatter = source of truth (this doc connects to these).
|
|
9
|
+
* - Backlinks = computed live (scan all docs' references, return those listing
|
|
10
|
+
* us). Cached in memory for query-time speed; invalidated on any references
|
|
11
|
+
* write.
|
|
12
|
+
* - Legacy `doc:` prose links in body keep rendering (TipTap PadLink) AND
|
|
13
|
+
* auto-populate `references` on save — backward compat.
|
|
14
|
+
* - Legacy stored `backlinks:` frontmatter field is dropped on any save
|
|
15
|
+
* (lazy migration). One-off `rebuildAllReferences()` does the bulk migrate.
|
|
11
16
|
*
|
|
12
|
-
*
|
|
13
|
-
*
|
|
14
|
-
*
|
|
15
|
-
*
|
|
16
|
-
* from_node: f6c3830d # source nodeId where link mark lives
|
|
17
|
-
* to_node: 1a2b3c4d # optional: target nodeId being linked to
|
|
17
|
+
* The pre-v0.20 incremental backlinks pipeline (updateBacklinksForSource) is
|
|
18
|
+
* gone — it had a race that meant on-save updates didn't fire reliably (the
|
|
19
|
+
* test session that motivated this refactor caught it). Computing live
|
|
20
|
+
* removes the entire class of incremental-update bugs.
|
|
18
21
|
*/
|
|
19
22
|
import { readFileSync, existsSync, readdirSync } from 'fs';
|
|
20
23
|
import { join } from 'path';
|
|
21
24
|
import matter from 'gray-matter';
|
|
22
25
|
import { getDataDir, atomicWriteFileSync, resolveDocPath, isExternalDoc } from './helpers.js';
|
|
23
|
-
import { filenameByDocId } from './documents.js';
|
|
24
26
|
import { markdownToTiptap } from './markdown-parse.js';
|
|
25
27
|
const HEX8 = /^[a-f0-9]{8}$/;
|
|
26
28
|
const ANCHOR_TEXT_MAX = 80; // truncate long anchor text in backlinks frontmatter
|
|
@@ -121,7 +123,7 @@ export function extractForwardLinks(doc, sourceDocId) {
|
|
|
121
123
|
* Read a doc's frontmatter from disk and parse it.
|
|
122
124
|
* Returns null if the file doesn't exist or can't be parsed.
|
|
123
125
|
*/
|
|
124
|
-
function readFrontmatter(filename) {
|
|
126
|
+
export function readFrontmatter(filename) {
|
|
125
127
|
try {
|
|
126
128
|
const filePath = resolveDocPath(filename);
|
|
127
129
|
if (!existsSync(filePath))
|
|
@@ -139,7 +141,7 @@ function readFrontmatter(filename) {
|
|
|
139
141
|
* Only touches the frontmatter — does NOT re-serialize the body, which would
|
|
140
142
|
* lose nodeIds and reformat. This is safe to call on non-active docs.
|
|
141
143
|
*/
|
|
142
|
-
function writeFrontmatter(filename, newData) {
|
|
144
|
+
export function writeFrontmatter(filename, newData) {
|
|
143
145
|
const filePath = resolveDocPath(filename);
|
|
144
146
|
const raw = readFileSync(filePath, 'utf-8');
|
|
145
147
|
const parsed = matter(raw);
|
|
@@ -157,85 +159,112 @@ function writeFrontmatter(filename, newData) {
|
|
|
157
159
|
return;
|
|
158
160
|
atomicWriteFileSync(filePath, newFrontmatter);
|
|
159
161
|
}
|
|
160
|
-
|
|
161
|
-
|
|
162
|
-
|
|
163
|
-
|
|
164
|
-
|
|
165
|
-
|
|
166
|
-
|
|
167
|
-
|
|
168
|
-
|
|
169
|
-
|
|
170
|
-
|
|
171
|
-
|
|
172
|
-
|
|
173
|
-
|
|
162
|
+
// ============================================================================
|
|
163
|
+
// COMPUTE-LIVE BACKLINKS — the new v0.20 surface
|
|
164
|
+
// ============================================================================
|
|
165
|
+
//
|
|
166
|
+
// `computeBacklinksFor(targetDocId)` returns every doc that lists targetDocId
|
|
167
|
+
// in its `references:` frontmatter array. Cached in an inverse-index map keyed
|
|
168
|
+
// by target docId. Any write that touches a source's references calls
|
|
169
|
+
// `invalidateBacklinksCache(sourceDocId)` to wipe affected entries; the next
|
|
170
|
+
// read rebuilds them lazily.
|
|
171
|
+
/** Inverse index: target docId → Set of source docIds that reference it. */
|
|
172
|
+
let backlinksCache = null;
|
|
173
|
+
/** Build (or rebuild) the entire inverse index by scanning every .md in the
|
|
174
|
+
* data dir. Runs O(N) over the corpus; called once on first read after an
|
|
175
|
+
* invalidation. Personal corpora ≤ a few hundred docs make this trivial. */
|
|
176
|
+
function buildBacklinksCache() {
|
|
177
|
+
const cache = new Map();
|
|
178
|
+
let files = [];
|
|
179
|
+
try {
|
|
180
|
+
files = readdirSync(getDataDir()).filter((f) => f.endsWith('.md'));
|
|
181
|
+
}
|
|
182
|
+
catch {
|
|
183
|
+
return cache;
|
|
184
|
+
}
|
|
185
|
+
for (const f of files) {
|
|
186
|
+
try {
|
|
187
|
+
const raw = readFileSync(join(getDataDir(), f), 'utf-8');
|
|
188
|
+
const parsed = matter(raw);
|
|
189
|
+
const sourceDocId = parsed.data?.docId;
|
|
190
|
+
if (!sourceDocId || typeof sourceDocId !== 'string')
|
|
191
|
+
continue;
|
|
192
|
+
const refs = parsed.data?.references;
|
|
193
|
+
if (!Array.isArray(refs))
|
|
194
|
+
continue;
|
|
195
|
+
for (const targetDocId of refs) {
|
|
196
|
+
if (typeof targetDocId !== 'string')
|
|
197
|
+
continue;
|
|
198
|
+
if (!cache.has(targetDocId))
|
|
199
|
+
cache.set(targetDocId, new Set());
|
|
200
|
+
cache.get(targetDocId).add(sourceDocId);
|
|
201
|
+
}
|
|
202
|
+
}
|
|
203
|
+
catch {
|
|
204
|
+
// skip unreadable
|
|
205
|
+
}
|
|
206
|
+
}
|
|
207
|
+
return cache;
|
|
208
|
+
}
|
|
209
|
+
/** Drop the in-memory cache. Next read rebuilds from disk. Called from
|
|
210
|
+
* state.ts:writeToDisk after a save that may have changed references. */
|
|
211
|
+
export function invalidateBacklinksCache() {
|
|
212
|
+
backlinksCache = null;
|
|
213
|
+
}
|
|
214
|
+
/**
|
|
215
|
+
* Return every source doc that references targetDocId. Pure read; the
|
|
216
|
+
* frontmatter `references:` arrays across the workspace are the only data
|
|
217
|
+
* consulted. Cached in memory.
|
|
218
|
+
*/
|
|
219
|
+
export function computeBacklinksFor(targetDocId) {
|
|
220
|
+
if (!backlinksCache)
|
|
221
|
+
backlinksCache = buildBacklinksCache();
|
|
222
|
+
const sources = backlinksCache.get(targetDocId);
|
|
223
|
+
if (!sources)
|
|
224
|
+
return [];
|
|
225
|
+
return Array.from(sources)
|
|
226
|
+
.sort()
|
|
227
|
+
.map((from_doc) => ({ from_doc }));
|
|
174
228
|
}
|
|
229
|
+
// ============================================================================
|
|
230
|
+
// PROSE-LINK AUTO-SYNC — backward compat for legacy [text](doc:id) prose links
|
|
231
|
+
// ============================================================================
|
|
175
232
|
/**
|
|
176
|
-
*
|
|
177
|
-
*
|
|
233
|
+
* Scan a TipTap doc for prose `doc:` links and merge their target docIds
|
|
234
|
+
* into the source's `references:` frontmatter. Idempotent — only writes
|
|
235
|
+
* when there are new docIds to add.
|
|
178
236
|
*
|
|
179
|
-
*
|
|
180
|
-
*
|
|
181
|
-
*
|
|
237
|
+
* Called from state.ts:writeToDisk after the markdown body is persisted, so
|
|
238
|
+
* existing prose links (which still render as click-through internal links
|
|
239
|
+
* via the PadLink TipTap extension) automatically appear in `references:`
|
|
240
|
+
* for graph/crawl/backlinks-panel consumption.
|
|
182
241
|
*/
|
|
183
|
-
export function
|
|
184
|
-
const
|
|
185
|
-
|
|
186
|
-
|
|
187
|
-
const
|
|
188
|
-
for (const
|
|
189
|
-
|
|
190
|
-
|
|
191
|
-
|
|
192
|
-
|
|
193
|
-
|
|
194
|
-
|
|
195
|
-
|
|
196
|
-
|
|
197
|
-
if (!fm)
|
|
198
|
-
continue;
|
|
199
|
-
// Pull all existing backlinks, drop ones from this source, then add new
|
|
200
|
-
const existing = Array.isArray(fm.data.backlinks) ? fm.data.backlinks : [];
|
|
201
|
-
const kept = existing.filter((b) => b.from_doc !== sourceDocId);
|
|
202
|
-
const fromThisSource = newLinks
|
|
203
|
-
.filter((l) => l.to_doc === targetDocId)
|
|
204
|
-
.map((l) => {
|
|
205
|
-
const entry = {
|
|
206
|
-
text: l.text,
|
|
207
|
-
from_doc: l.from_doc,
|
|
208
|
-
from_node: l.from_node,
|
|
209
|
-
};
|
|
210
|
-
if (l.to_node)
|
|
211
|
-
entry.to_node = l.to_node;
|
|
212
|
-
return entry;
|
|
213
|
-
});
|
|
214
|
-
const updated = [...kept, ...fromThisSource];
|
|
215
|
-
// Stable ordering for diff-friendliness: by from_doc, from_node
|
|
216
|
-
updated.sort((a, b) => {
|
|
217
|
-
if (a.from_doc !== b.from_doc)
|
|
218
|
-
return a.from_doc < b.from_doc ? -1 : 1;
|
|
219
|
-
return a.from_node < b.from_node ? -1 : 1;
|
|
220
|
-
});
|
|
221
|
-
const newData = { ...fm.data };
|
|
222
|
-
if (updated.length > 0)
|
|
223
|
-
newData.backlinks = updated;
|
|
224
|
-
else
|
|
225
|
-
delete newData.backlinks;
|
|
226
|
-
try {
|
|
227
|
-
writeFrontmatter(targetFilename, newData);
|
|
228
|
-
touched.push(targetDocId);
|
|
229
|
-
}
|
|
230
|
-
catch {
|
|
231
|
-
// Best-effort — skip on error
|
|
242
|
+
export function syncReferencesFromProse(sourceDocId, sourceDoc, currentMetadata) {
|
|
243
|
+
const links = extractForwardLinks(sourceDoc, sourceDocId);
|
|
244
|
+
if (links.length === 0)
|
|
245
|
+
return null;
|
|
246
|
+
const proseTargets = new Set();
|
|
247
|
+
for (const l of links)
|
|
248
|
+
proseTargets.add(l.to_doc);
|
|
249
|
+
const existing = Array.isArray(currentMetadata.references) ? currentMetadata.references : [];
|
|
250
|
+
const merged = new Set(existing);
|
|
251
|
+
const added = [];
|
|
252
|
+
for (const t of proseTargets) {
|
|
253
|
+
if (!merged.has(t)) {
|
|
254
|
+
merged.add(t);
|
|
255
|
+
added.push(t);
|
|
232
256
|
}
|
|
233
257
|
}
|
|
234
|
-
|
|
258
|
+
if (added.length === 0)
|
|
259
|
+
return null;
|
|
260
|
+
return { added, newReferences: Array.from(merged) };
|
|
235
261
|
}
|
|
262
|
+
// ============================================================================
|
|
263
|
+
// MIGRATION — bulk backfill from prose links + strip stored backlinks
|
|
264
|
+
// ============================================================================
|
|
236
265
|
/**
|
|
237
266
|
* Read all docs in the data dir, return their parsed frontmatter + tiptap doc.
|
|
238
|
-
* Used by
|
|
267
|
+
* Used by the migration rebuild.
|
|
239
268
|
*/
|
|
240
269
|
function loadAllDocsForRebuild() {
|
|
241
270
|
const out = [];
|
|
@@ -253,7 +282,7 @@ function loadAllDocsForRebuild() {
|
|
|
253
282
|
const docId = parsed.metadata?.docId;
|
|
254
283
|
if (!docId)
|
|
255
284
|
continue;
|
|
256
|
-
out.push({ docId, filename: f, doc: parsed.document });
|
|
285
|
+
out.push({ docId, filename: f, doc: parsed.document, metadata: parsed.metadata });
|
|
257
286
|
}
|
|
258
287
|
catch {
|
|
259
288
|
// skip unreadable
|
|
@@ -262,37 +291,39 @@ function loadAllDocsForRebuild() {
|
|
|
262
291
|
return out;
|
|
263
292
|
}
|
|
264
293
|
/**
|
|
265
|
-
* Full
|
|
266
|
-
*
|
|
267
|
-
*
|
|
294
|
+
* Full rescan: for every doc, extract prose `doc:` links from body and merge
|
|
295
|
+
* their targets into `references:` frontmatter. Also strip any legacy
|
|
296
|
+
* `backlinks:` field. Idempotent — re-running produces no changes if the
|
|
297
|
+
* corpus is already migrated.
|
|
298
|
+
*
|
|
299
|
+
* Replaces the v0.19 `rebuildAllBacklinks` which built the (now-removed)
|
|
300
|
+
* derived backlinks projection. The new rescue path is `/api/rebuild-references`
|
|
301
|
+
* (with `/api/rebuild-backlinks` kept as a 308 redirect for one release cycle).
|
|
268
302
|
*/
|
|
269
|
-
export function
|
|
303
|
+
export function rebuildAllReferences() {
|
|
270
304
|
const allDocs = loadAllDocsForRebuild();
|
|
271
|
-
// Collect every forward link in the workspace
|
|
272
|
-
const allLinks = [];
|
|
273
|
-
for (const d of allDocs) {
|
|
274
|
-
allLinks.push(...extractForwardLinks(d.doc, d.docId));
|
|
275
|
-
}
|
|
276
|
-
// For each doc, compute its inbound = backlinks, write if changed
|
|
277
305
|
let updated = 0;
|
|
278
306
|
for (const d of allDocs) {
|
|
279
|
-
const newBacklinks = toBacklinks(d.docId, allLinks);
|
|
280
|
-
newBacklinks.sort((a, b) => {
|
|
281
|
-
if (a.from_doc !== b.from_doc)
|
|
282
|
-
return a.from_doc < b.from_doc ? -1 : 1;
|
|
283
|
-
return a.from_node < b.from_node ? -1 : 1;
|
|
284
|
-
});
|
|
285
307
|
const fm = readFrontmatter(d.filename);
|
|
286
308
|
if (!fm)
|
|
287
309
|
continue;
|
|
288
|
-
|
|
289
|
-
|
|
310
|
+
// Extract prose links → docIds
|
|
311
|
+
const proseLinks = extractForwardLinks(d.doc, d.docId);
|
|
312
|
+
const proseTargets = new Set(proseLinks.map((l) => l.to_doc));
|
|
313
|
+
// Merge with existing references (dedup)
|
|
314
|
+
const existing = Array.isArray(fm.data.references) ? fm.data.references : [];
|
|
315
|
+
const merged = Array.from(new Set([...existing, ...proseTargets])).sort();
|
|
316
|
+
// Decide whether anything changed
|
|
317
|
+
const referencesChanged = JSON.stringify(existing.slice().sort()) !== JSON.stringify(merged);
|
|
318
|
+
const hadLegacyBacklinks = 'backlinks' in fm.data;
|
|
319
|
+
if (!referencesChanged && !hadLegacyBacklinks)
|
|
290
320
|
continue;
|
|
291
321
|
const newData = { ...fm.data };
|
|
292
|
-
if (
|
|
293
|
-
newData.
|
|
322
|
+
if (merged.length > 0)
|
|
323
|
+
newData.references = merged;
|
|
294
324
|
else
|
|
295
|
-
delete newData.
|
|
325
|
+
delete newData.references;
|
|
326
|
+
delete newData.backlinks; // lazy migration
|
|
296
327
|
try {
|
|
297
328
|
writeFrontmatter(d.filename, newData);
|
|
298
329
|
updated++;
|
|
@@ -301,8 +332,17 @@ export function rebuildAllBacklinks() {
|
|
|
301
332
|
// skip
|
|
302
333
|
}
|
|
303
334
|
}
|
|
335
|
+
invalidateBacklinksCache();
|
|
304
336
|
return { scanned: allDocs.length, updated };
|
|
305
337
|
}
|
|
338
|
+
/**
|
|
339
|
+
* @deprecated v0.20 — kept as a no-op shim so any caller imports still work.
|
|
340
|
+
* The incremental backlinks pipeline is gone; backlinks compute live. State's
|
|
341
|
+
* writeToDisk no longer calls this.
|
|
342
|
+
*/
|
|
343
|
+
export function updateBacklinksForSource() {
|
|
344
|
+
return { touched: [] };
|
|
345
|
+
}
|
|
306
346
|
/**
|
|
307
347
|
* Read previously-saved markdown from disk for a given source filename
|
|
308
348
|
* and extract its forward links. Used by the save hook to compute the
|
package/dist/server/index.js
CHANGED
|
@@ -286,13 +286,38 @@ export async function startHttpServer(options = {}) {
|
|
|
286
286
|
app.get('/api/documents', (_req, res) => {
|
|
287
287
|
res.json(listDocuments());
|
|
288
288
|
});
|
|
289
|
-
//
|
|
290
|
-
//
|
|
291
|
-
//
|
|
289
|
+
// References: get the live computed inverse for a target docId. Returns
|
|
290
|
+
// every source doc that lists this docId in its `references:` frontmatter.
|
|
291
|
+
// Cached server-side; cache invalidated on any save that touches references.
|
|
292
|
+
app.get('/api/backlinks/:docId', async (req, res) => {
|
|
293
|
+
try {
|
|
294
|
+
const { computeBacklinksFor } = await import('./backlinks.js');
|
|
295
|
+
res.json(computeBacklinksFor(req.params.docId));
|
|
296
|
+
}
|
|
297
|
+
catch (err) {
|
|
298
|
+
res.status(500).json({ error: err.message });
|
|
299
|
+
}
|
|
300
|
+
});
|
|
301
|
+
// References: full rebuild across all docs (idempotent rescue path).
|
|
302
|
+
// Walks every .md, extracts legacy prose `doc:` links from body, merges
|
|
303
|
+
// their targets into `references:`, strips any legacy `backlinks:` field.
|
|
304
|
+
// Idempotent — safe to re-run.
|
|
305
|
+
app.post('/api/rebuild-references', async (_req, res) => {
|
|
306
|
+
try {
|
|
307
|
+
const { rebuildAllReferences } = await import('./backlinks.js');
|
|
308
|
+
const result = rebuildAllReferences();
|
|
309
|
+
res.json(result);
|
|
310
|
+
}
|
|
311
|
+
catch (err) {
|
|
312
|
+
res.status(500).json({ error: err.message });
|
|
313
|
+
}
|
|
314
|
+
});
|
|
315
|
+
// Legacy alias: kept for one release cycle so existing scripts/agents
|
|
316
|
+
// pointing at the old path still work. Forwards to the new endpoint.
|
|
292
317
|
app.post('/api/rebuild-backlinks', async (_req, res) => {
|
|
293
318
|
try {
|
|
294
|
-
const {
|
|
295
|
-
const result =
|
|
319
|
+
const { rebuildAllReferences } = await import('./backlinks.js');
|
|
320
|
+
const result = rebuildAllReferences();
|
|
296
321
|
res.json(result);
|
|
297
322
|
}
|
|
298
323
|
catch (err) {
|