openwriter 0.19.0 → 0.20.1

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.
@@ -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-BZ7LCzrR.js"></script>
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>
@@ -1,26 +1,28 @@
1
1
  /**
2
- * Backlinks engine: keeps each doc's frontmatter `backlinks` field in sync
3
- * with the forward links pointing at it from other docs.
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
- * - Forward links in prose = source of truth (link mark with `doc:` href).
7
- * - Backlinks frontmatter = derived projection, eventually consistent.
8
- * - Incremental on save: when a doc's forward links change, only the
9
- * affected target docs get their backlinks refreshed.
10
- * - Full rebuild via /api/rebuild-backlinks (idempotent rescue path).
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
- * Frontmatter schema (lean anchor text + refs only, no snippet/context):
13
- * backlinks:
14
- * - text: "the territorial imperative"
15
- * from_doc: a3f2c1d4 # source docId
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
- /** Convert ForwardLinks targeting a given doc into Backlink entries for its frontmatter. */
161
- function toBacklinks(targetDocId, allLinks) {
162
- return allLinks
163
- .filter((l) => l.to_doc === targetDocId)
164
- .map((l) => {
165
- const entry = {
166
- text: l.text,
167
- from_doc: l.from_doc,
168
- from_node: l.from_node,
169
- };
170
- if (l.to_node)
171
- entry.to_node = l.to_node;
172
- return entry;
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
- * Incremental update: source doc's forward links changed from oldLinks to newLinks.
177
- * Update each affected target doc's backlinks frontmatter.
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
- * If `currentDocMetadata` is provided, it's the live in-memory metadata for the
180
- * source doc (the active doc). The caller is responsible for persisting it.
181
- * For OTHER target docs we touch their files directly.
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 updateBacklinksForSource(sourceDocId, newLinks, oldLinks) {
184
- const oldTargets = new Set(oldLinks.map((l) => l.to_doc));
185
- const newTargets = new Set(newLinks.map((l) => l.to_doc));
186
- const affected = new Set([...oldTargets, ...newTargets]);
187
- const touched = [];
188
- for (const targetDocId of affected) {
189
- if (targetDocId === sourceDocId)
190
- continue; // Skip self-links (rare; if any, handled by caller)
191
- const targetFilename = filenameByDocId(targetDocId);
192
- if (!targetFilename) {
193
- // Target doc not found anywhere — broken link, source-side surface added in a follow-up
194
- continue;
195
- }
196
- const fm = readFrontmatter(targetFilename);
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
- return { touched };
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 full rebuild.
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 rebuild: scan all docs, compute backlinks for each from scratch,
266
- * write updated frontmatter to docs whose backlinks changed.
267
- * Idempotent. Run via /api/rebuild-backlinks.
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 rebuildAllBacklinks() {
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
- const existing = Array.isArray(fm.data.backlinks) ? fm.data.backlinks : [];
289
- if (JSON.stringify(existing) === JSON.stringify(newBacklinks))
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 (newBacklinks.length > 0)
293
- newData.backlinks = newBacklinks;
322
+ if (merged.length > 0)
323
+ newData.references = merged;
294
324
  else
295
- delete newData.backlinks;
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
@@ -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
- // Backlinks: full rebuild across all docs (idempotent rescue path).
290
- // The normal flow updates backlinks incrementally on each save; this endpoint
291
- // exists for repair after external edits or to bootstrap an unmigrated workspace.
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 { rebuildAllBacklinks } = await import('./backlinks.js');
295
- const result = rebuildAllBacklinks();
319
+ const { rebuildAllReferences } = await import('./backlinks.js');
320
+ const result = rebuildAllReferences();
296
321
  res.json(result);
297
322
  }
298
323
  catch (err) {