openwriter 0.18.1 → 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/documents.js +10 -17
- package/dist/server/index.js +30 -5
- package/dist/server/mcp.js +129 -163
- package/dist/server/state.js +51 -17
- package/package.json +1 -1
package/dist/server/mcp.js
CHANGED
|
@@ -14,7 +14,7 @@ import { getDocument, getWordCount, getPendingChangeCount, getTitle, getStatus,
|
|
|
14
14
|
import { tiptapToBlocks } from './node-blocks.js';
|
|
15
15
|
import { harvestSentenceHashes, harvestCharCount } from './enrichment.js';
|
|
16
16
|
import { listDocuments, switchDocument, createDocument, createDocumentFile, deleteDocument, openFile, getActiveFilename, updateDocumentTitle, promoteTempFile, archiveDocument, unarchiveDocument, resolveDocId, filenameByDocId, searchDocuments, listDirtyDocs, crawlDocs, enrichmentFooter, buildEnrichmentInstructions } from './documents.js';
|
|
17
|
-
import {
|
|
17
|
+
import { readFrontmatter, writeFrontmatter, computeBacklinksFor, invalidateBacklinksCache } from './backlinks.js';
|
|
18
18
|
import { logger, generateRequestId, withRequestId } from './logger.js';
|
|
19
19
|
import { broadcastDocumentSwitched, broadcastDocumentsChanged, broadcastWorkspacesChanged, broadcastTitleChanged, broadcastMetadataChanged, broadcastPendingDocsChanged, broadcastWritingStarted, broadcastWritingFinished, broadcastCommentsChanged } from './ws.js';
|
|
20
20
|
import { listWorkspaces, getWorkspace, getDocTitle, getItemContext, addDoc, updateWorkspaceContext, createWorkspace, deleteWorkspace, addContainerToWorkspace, findOrCreateWorkspace, findOrCreateContainer, moveDoc, moveContainer, reorderWorkspaceAfter, removeContainer, renameWorkspace, renameContainer, removeDocFromAllWorkspaces, findWorkspacesContainingDoc, collectFilesInWorkspace } from './workspaces.js';
|
|
@@ -363,7 +363,7 @@ export const TOOL_REGISTRY = [
|
|
|
363
363
|
},
|
|
364
364
|
{
|
|
365
365
|
name: 'list_documents',
|
|
366
|
-
description: 'List all documents. Shows title, docId, word count, last modified, active flag, and enrichment fields (logline,
|
|
366
|
+
description: 'List all documents. Shows title, docId, word count, last modified, active flag, and enrichment fields (logline, status, STALE marker) when present. Use the docId to target documents in other tools. v0.19.0: three-field enrichment schema — logline (LLM), status (agent: canonical / draft), STALE (system).',
|
|
367
367
|
schema: {},
|
|
368
368
|
handler: async () => {
|
|
369
369
|
const docs = listDocuments();
|
|
@@ -372,10 +372,10 @@ export const TOOL_REGISTRY = [
|
|
|
372
372
|
const id = d.docId ? ` [${d.docId}]` : '';
|
|
373
373
|
const date = d.lastModified.split('T')[0];
|
|
374
374
|
const enrichBits = [];
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
if (d.
|
|
378
|
-
enrichBits.push(
|
|
375
|
+
// v0.19.0: only canonical surfaces — draft is the default and would
|
|
376
|
+
// clutter the listing on every doc.
|
|
377
|
+
if (d.status === 'canonical')
|
|
378
|
+
enrichBits.push('canonical');
|
|
379
379
|
if (d.enrichmentStale === true)
|
|
380
380
|
enrichBits.push('STALE');
|
|
381
381
|
const enrichTag = enrichBits.length > 0 ? ` (${enrichBits.join(', ')})` : '';
|
|
@@ -415,8 +415,9 @@ export const TOOL_REGISTRY = [
|
|
|
415
415
|
content_type: z.enum(['document', 'tweet', 'reply', 'quote', 'article', 'linkedin', 'newsletter', 'blog']).describe('Required. Use "document" for plain documents. Tweet/reply/quote/article/linkedin/newsletter/blog set type-specific metadata automatically.'),
|
|
416
416
|
url: z.string().optional().describe('Tweet URL — REQUIRED for content_type "reply" or "quote" (e.g. "https://x.com/user/status/123"). Sets tweetContext.url automatically. Ignored for other content types.'),
|
|
417
417
|
afterId: z.string().optional().describe('Place the new doc immediately after this docId (8-char hex) or containerId inside its parent. Omit to append to the bottom of the parent (the default — matches ascending-order convention: newest at bottom). Requires workspace.'),
|
|
418
|
+
status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle. "canonical" = committed to spine / load-bearing for the workspace (use for Beats docs that have locked, Research Notes, Master References). "draft" = working / not load-bearing yet / scratch (DUMP docs, first-pass beats). Defaults to "draft" when omitted. Change later via set_metadata({ status: ... }) on lifecycle transitions. v0.19.0.'),
|
|
418
419
|
},
|
|
419
|
-
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId }) => {
|
|
420
|
+
handler: async ({ title, path, workspace, container, empty, content_type, url, afterId, status }) => {
|
|
420
421
|
// Require url for reply/quote
|
|
421
422
|
if ((content_type === 'reply' || content_type === 'quote') && !url) {
|
|
422
423
|
return { content: [{ type: 'text', text: `Error: content_type "${content_type}" requires a url parameter (e.g. "https://x.com/user/status/123").` }] };
|
|
@@ -444,18 +445,25 @@ export const TOOL_REGISTRY = [
|
|
|
444
445
|
// Track the spinner key so catch can clear exactly this entry
|
|
445
446
|
// (not siblings from a concurrent declare_writes).
|
|
446
447
|
let spinnerKey = null;
|
|
448
|
+
// v0.19.0: agent-owned status. Defaults to "draft" when not supplied —
|
|
449
|
+
// canonical is reserved for docs that have committed to the workspace
|
|
450
|
+
// spine (Beats, Research Notes, Master References). Agent flips to
|
|
451
|
+
// canonical via set_metadata({ status: "canonical" }) on lifecycle
|
|
452
|
+
// transitions. See brief 2026-05-21-simplify-enrichment-schema-three-fields.
|
|
453
|
+
const statusMeta = { status: status ?? 'draft' };
|
|
447
454
|
try {
|
|
448
455
|
if (empty) {
|
|
449
456
|
// Immediate switch — no spinner, no populate_document needed
|
|
450
457
|
const result = createDocument(title, undefined, path);
|
|
451
458
|
setAgentLock(result.filename);
|
|
452
|
-
// Apply type-specific metadata
|
|
459
|
+
// Apply status + type-specific metadata in one merge
|
|
460
|
+
const initMeta = { ...statusMeta };
|
|
453
461
|
if (content_type) {
|
|
454
462
|
const typeMeta = resolveTypeMeta(content_type, url);
|
|
455
|
-
if (typeMeta)
|
|
456
|
-
|
|
457
|
-
}
|
|
463
|
+
if (typeMeta)
|
|
464
|
+
Object.assign(initMeta, typeMeta);
|
|
458
465
|
}
|
|
466
|
+
setMetadata(initMeta);
|
|
459
467
|
let wsInfo = '';
|
|
460
468
|
if (wsTarget) {
|
|
461
469
|
// Resolve afterId: it may be a docId (8-char hex) or containerId.
|
|
@@ -478,8 +486,11 @@ export const TOOL_REGISTRY = [
|
|
|
478
486
|
}
|
|
479
487
|
// Two-step flow: create file on disk WITHOUT switching the user's view.
|
|
480
488
|
// The spinner persists in the sidebar until populate_document is called.
|
|
489
|
+
// Merge status with any content-type metadata so it lands on the first
|
|
490
|
+
// disk write.
|
|
481
491
|
const typeMeta = content_type ? resolveTypeMeta(content_type, url) : undefined;
|
|
482
|
-
const
|
|
492
|
+
const initialMeta = { ...statusMeta, ...(typeMeta || {}) };
|
|
493
|
+
const result = createDocumentFile(title, path, initialMeta);
|
|
483
494
|
let wsInfo = '';
|
|
484
495
|
if (wsTarget) {
|
|
485
496
|
const afterRef = afterId ? (filenameByDocId(afterId) ?? afterId) : null;
|
|
@@ -556,6 +567,28 @@ export const TOOL_REGISTRY = [
|
|
|
556
567
|
if (!isAutoAcceptActive(filename || getActiveFilename(), getMetadata())) {
|
|
557
568
|
markAllNodesAsPending(doc, 'insert');
|
|
558
569
|
}
|
|
570
|
+
// Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
|
|
571
|
+
// updateDocument(doc) overwrites state.canonical wholesale — without this
|
|
572
|
+
// merge, the create_document → populate_document sequence loses the stub's
|
|
573
|
+
// auto-generated trailing paragraph from canonical. When the browser later
|
|
574
|
+
// accepts the inserts and sends a doc-update with its TipTap-rendered tree
|
|
575
|
+
// (which also has a trailing empty paragraph, but a different ID), the
|
|
576
|
+
// save-time matcher classifies the stub's original trailing as deleted →
|
|
577
|
+
// graveyard, while the freshly added inserts have no previousNodes match.
|
|
578
|
+
// Cascading state corruption observed in live test 2026-05-22.
|
|
579
|
+
const existingCanonical = getCanonical();
|
|
580
|
+
if (existingCanonical?.content?.length) {
|
|
581
|
+
const incomingIds = new Set(doc.content
|
|
582
|
+
.map((n) => n?.attrs?.id)
|
|
583
|
+
.filter((id) => typeof id === 'string'));
|
|
584
|
+
const preserved = existingCanonical.content.filter((n) => {
|
|
585
|
+
const id = n?.attrs?.id;
|
|
586
|
+
return id && !incomingIds.has(id);
|
|
587
|
+
});
|
|
588
|
+
if (preserved.length > 0) {
|
|
589
|
+
doc.content = [...doc.content, ...preserved];
|
|
590
|
+
}
|
|
591
|
+
}
|
|
559
592
|
updateDocument(doc);
|
|
560
593
|
updatePendingCacheForActiveDoc();
|
|
561
594
|
save();
|
|
@@ -733,7 +766,7 @@ export const TOOL_REGISTRY = [
|
|
|
733
766
|
},
|
|
734
767
|
{
|
|
735
768
|
name: 'set_metadata',
|
|
736
|
-
description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately.',
|
|
769
|
+
description: 'Update frontmatter metadata on a document. Merges with existing metadata — only provided keys are changed. Use for summaries, character lists, tags, arc notes, or any organizational data. Saves to disk immediately. Lifecycle convention (v0.19.0): use `set_metadata({ status: "canonical" })` when a doc commits to the workspace spine (Beats locks, Research Note becomes load-bearing); use `set_metadata({ status: "draft" })` when a doc is superseded or demoted. Status is the agent\'s field — the enrichment minion never writes it.',
|
|
737
770
|
schema: {
|
|
738
771
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
739
772
|
metadata: z.record(z.any()).describe('Key-value pairs to merge into frontmatter. Set a key to null to remove it.'),
|
|
@@ -801,16 +834,12 @@ export const TOOL_REGISTRY = [
|
|
|
801
834
|
},
|
|
802
835
|
{
|
|
803
836
|
name: 'mark_enriched',
|
|
804
|
-
description: 'Mark one or more documents as freshly enriched. Stamps openwriter-maintained baselines (lastEnrichedAt, lastEnrichedCharCount, lastEnrichedSentences) atomically with the supplied enrichment fields, and
|
|
837
|
+
description: 'Mark one or more documents as freshly enriched. Stamps openwriter-maintained baselines (lastEnrichedAt, lastEnrichedCharCount, lastEnrichedSentences) atomically with the supplied logline, clears enrichmentStale, and retires legacy enrichment fields (domain, concepts, docRole, and any LLM-written status). The agent never touches the sentence-hash layer — openwriter computes the baseline from current canonical content. Accepts an array so a workspace-wide sweep is one call. Schema simplified in v0.19.0: only logline is LLM-written; status is now agent-owned via create_document / set_metadata; domain / concepts / docRole are gone. See brief 2026-05-21-simplify-enrichment-schema-three-fields.',
|
|
805
838
|
schema: {
|
|
806
839
|
docs: z.array(z.object({
|
|
807
840
|
docId: z.string().describe('Target document by docId (8-char hex from list_documents).'),
|
|
808
|
-
logline: z.string().
|
|
809
|
-
|
|
810
|
-
concepts: z.array(z.string()).optional().describe('Named concepts the doc references.'),
|
|
811
|
-
docRole: z.string().optional().describe('Doc role: canonical / vignette / reference / draft / chapter / beat.'),
|
|
812
|
-
status: z.string().optional().describe('Doc status: draft / canonical / stale. Archive state is implied by archivedAt.'),
|
|
813
|
-
})).describe('One or more docs to mark enriched. Single-doc calls are a length-1 array.'),
|
|
841
|
+
logline: z.string().max(150).describe('Précis (non-fiction) or logline (fiction). Under 150 chars. Describe the content, not the kind of doc.'),
|
|
842
|
+
}).strict()).describe('One or more docs to mark enriched. Single-doc calls are a length-1 array. Strict schema — passing domain / concepts / docRole / status will fail validation (v0.19.0 schema simplification).'),
|
|
814
843
|
},
|
|
815
844
|
handler: async ({ docs }) => {
|
|
816
845
|
const now = new Date().toISOString();
|
|
@@ -828,23 +857,19 @@ export const TOOL_REGISTRY = [
|
|
|
828
857
|
const blocks = tiptapToBlocks(canonical);
|
|
829
858
|
const lastEnrichedSentences = harvestSentenceHashes(blocks);
|
|
830
859
|
const lastEnrichedCharCount = harvestCharCount(blocks);
|
|
831
|
-
// Build the atomic enrichment payload.
|
|
860
|
+
// Build the atomic enrichment payload. v0.19.0: only logline is
|
|
861
|
+
// LLM-written. The legacy fields (domain / concepts / docRole) get
|
|
862
|
+
// retired on this write — `LEGACY_FIELDS_TO_RETIRE` is deleted from
|
|
863
|
+
// the merged metadata so disk slowly converges to the new schema
|
|
864
|
+
// as each doc gets re-enriched (lazy migration path from the brief).
|
|
832
865
|
const update = {
|
|
833
866
|
lastEnrichedAt: now,
|
|
834
867
|
lastEnrichedCharCount,
|
|
835
868
|
lastEnrichedSentences,
|
|
836
869
|
enrichmentStale: false,
|
|
870
|
+
logline: item.logline,
|
|
837
871
|
};
|
|
838
|
-
|
|
839
|
-
update.logline = item.logline;
|
|
840
|
-
if (item.domain !== undefined)
|
|
841
|
-
update.domain = item.domain;
|
|
842
|
-
if (item.concepts !== undefined)
|
|
843
|
-
update.concepts = item.concepts;
|
|
844
|
-
if (item.docRole !== undefined)
|
|
845
|
-
update.docRole = item.docRole;
|
|
846
|
-
if (item.status !== undefined)
|
|
847
|
-
update.status = item.status;
|
|
872
|
+
const LEGACY_FIELDS_TO_RETIRE = ['domain', 'concepts', 'docRole'];
|
|
848
873
|
if (target.isActive) {
|
|
849
874
|
// Active doc: setMetadata mutates state.metadata but doesn't bump
|
|
850
875
|
// docVersion on its own — without an explicit bump, save() would
|
|
@@ -853,6 +878,9 @@ export const TOOL_REGISTRY = [
|
|
|
853
878
|
// writeToDisk's staleness check will see the just-stamped baseline
|
|
854
879
|
// (volumeRatio=1, drift=0) and NOT flip the flag back to true.
|
|
855
880
|
setMetadata(update);
|
|
881
|
+
const liveMeta = getMetadata();
|
|
882
|
+
for (const k of LEGACY_FIELDS_TO_RETIRE)
|
|
883
|
+
delete liveMeta[k];
|
|
856
884
|
bumpDocVersion();
|
|
857
885
|
save();
|
|
858
886
|
broadcastMetadataChanged(getMetadata());
|
|
@@ -863,6 +891,8 @@ export const TOOL_REGISTRY = [
|
|
|
863
891
|
// serialize cycle before the new baseline lands). Disk write +
|
|
864
892
|
// cache invalidation mirrors set_metadata's non-active path.
|
|
865
893
|
const newMeta = { ...target.metadata, ...update };
|
|
894
|
+
for (const k of LEGACY_FIELDS_TO_RETIRE)
|
|
895
|
+
delete newMeta[k];
|
|
866
896
|
const markdown = tiptapToMarkdown(target.document, target.title, newMeta);
|
|
867
897
|
atomicWriteFileSync(target.filePath, markdown);
|
|
868
898
|
invalidateDocCache(target.filePath);
|
|
@@ -896,13 +926,11 @@ export const TOOL_REGISTRY = [
|
|
|
896
926
|
},
|
|
897
927
|
{
|
|
898
928
|
name: 'crawl',
|
|
899
|
-
description: 'Bulk-read enriched fields per doc, filtered by criteria. The crawl primitive — agents use this to scan the workspace shelf at concept level (~
|
|
929
|
+
description: 'Bulk-read enriched fields per doc, filtered by criteria. The crawl primitive — agents use this to scan the workspace shelf at concept level (~60 tokens/doc) and decide which bodies to actually read. Filters compose with AND semantics. Empty filter returns every non-archived doc. No bodies, no nodes, no pending overlay. v0.19.0 schema: status (canonical / draft) replaces docRole / domain / concepts filters — those legacy filters were dropped because the fields they queried had no authority discipline. See brief 2026-05-21-simplify-enrichment-schema-three-fields.',
|
|
900
930
|
schema: {
|
|
901
931
|
workspaceFile: z.string().optional().describe('Scope to one workspace.'),
|
|
902
|
-
domain: z.string().optional().describe('Exact domain match.'),
|
|
903
932
|
tags: z.array(z.string()).optional().describe('Docs must have ALL listed tags.'),
|
|
904
|
-
|
|
905
|
-
docRole: z.string().optional().describe('Exact docRole match (canonical / vignette / reference / draft / chapter / beat).'),
|
|
933
|
+
status: z.enum(['canonical', 'draft']).optional().describe('Agent-owned lifecycle filter. "canonical" returns the trusted-shelf docs (load-bearing for the workspace); "draft" returns working / superseded / scratch docs. The common crawl is `status: canonical`.'),
|
|
906
934
|
hasLogline: z.boolean().optional().describe('True = only docs with a logline; false = only docs without one.'),
|
|
907
935
|
},
|
|
908
936
|
handler: async (filter) => {
|
|
@@ -953,7 +981,7 @@ export const TOOL_REGISTRY = [
|
|
|
953
981
|
},
|
|
954
982
|
{
|
|
955
983
|
name: 'get_workspace_structure',
|
|
956
|
-
description: 'Get the full structure of a workspace: tree of containers and docs, per-doc enrichment (logline,
|
|
984
|
+
description: 'Get the full structure of a workspace: tree of containers and docs, per-doc enrichment (logline, status, tags, stale flag), plus workspace-level context (characters, settings, rules) and enrichment metadata (schema, vocab, logline). Use to understand a workspace at concept level before reading bodies. v0.19.0: enrichment fields shown per-doc are logline (LLM-owned), status (agent-owned: canonical / draft), tags, and the STALE marker (system-owned).',
|
|
957
985
|
schema: {
|
|
958
986
|
filename: z.string().describe('Workspace manifest filename (e.g. "my-novel-a1b2c3d4.json")'),
|
|
959
987
|
},
|
|
@@ -970,10 +998,11 @@ export const TOOL_REGISTRY = [
|
|
|
970
998
|
const e = enrichByFile.get(node.file);
|
|
971
999
|
const tags = e?.tags ?? [];
|
|
972
1000
|
const enrichBits = [];
|
|
973
|
-
|
|
974
|
-
|
|
975
|
-
|
|
976
|
-
|
|
1001
|
+
// v0.19.0: status (agent-owned) replaces domain + docRole.
|
|
1002
|
+
// Only "canonical" is worth surfacing — draft is the default
|
|
1003
|
+
// and would add noise on every line.
|
|
1004
|
+
if (e?.status === 'canonical')
|
|
1005
|
+
enrichBits.push('canonical');
|
|
977
1006
|
if (tags.length > 0)
|
|
978
1007
|
enrichBits.push(`tags: ${tags.join(', ')}`);
|
|
979
1008
|
if (e?.enrichmentStale === true)
|
|
@@ -1023,7 +1052,7 @@ export const TOOL_REGISTRY = [
|
|
|
1023
1052
|
},
|
|
1024
1053
|
{
|
|
1025
1054
|
name: 'get_item_context',
|
|
1026
|
-
description: 'Get progressive-disclosure context for a document: workspace-level context (characters, settings, rules, vocab), the doc\'s own enrichment (logline,
|
|
1055
|
+
description: 'Get progressive-disclosure context for a document: workspace-level context (characters, settings, rules, vocab), the doc\'s own enrichment (logline, status), tags, and the enrichmentStale flag. Use before writing to understand context, or before reading to decide whether a body read is necessary. v0.19.0: returns the three-field enrichment schema — logline (LLM), status (agent), enrichmentStale (system).',
|
|
1027
1056
|
schema: {
|
|
1028
1057
|
workspaceFile: z.string().describe('Workspace manifest filename'),
|
|
1029
1058
|
docId: z.string().describe('Document docId (8-char hex from list_documents)'),
|
|
@@ -1039,14 +1068,10 @@ export const TOOL_REGISTRY = [
|
|
|
1039
1068
|
const enriched = crawlDocs({ workspaceFile });
|
|
1040
1069
|
const docEnrich = enriched.find((e) => e.filename === filename);
|
|
1041
1070
|
if (docEnrich) {
|
|
1071
|
+
// v0.19.0 three-field schema: logline (LLM), status (agent),
|
|
1072
|
+
// enrichmentStale (system). domain / concepts / docRole dropped.
|
|
1042
1073
|
if (docEnrich.logline)
|
|
1043
1074
|
base.logline = docEnrich.logline;
|
|
1044
|
-
if (docEnrich.domain)
|
|
1045
|
-
base.domain = docEnrich.domain;
|
|
1046
|
-
if (docEnrich.concepts)
|
|
1047
|
-
base.concepts = docEnrich.concepts;
|
|
1048
|
-
if (docEnrich.docRole)
|
|
1049
|
-
base.docRole = docEnrich.docRole;
|
|
1050
1075
|
if (docEnrich.status)
|
|
1051
1076
|
base.status = docEnrich.status;
|
|
1052
1077
|
if (docEnrich.enrichmentStale === true)
|
|
@@ -1684,115 +1709,69 @@ export const TOOL_REGISTRY = [
|
|
|
1684
1709
|
},
|
|
1685
1710
|
{
|
|
1686
1711
|
name: 'link_to',
|
|
1687
|
-
description: '
|
|
1712
|
+
description: 'Declare a structural doc-to-doc connection. Writes target_doc_id into the source doc\'s `references:` frontmatter array. Body markdown is NEVER mutated — this is metadata, not prose. Idempotent: calling twice with the same source/target is a no-op. The inbound list on the target is computed live from the inverse of every doc\'s references — no stored derived field. v0.20.0 breaking change: dropped `text`, `target_node_id`, and `quote` parameters; connections are structural, not anchored to prose. Legacy prose `doc:` links continue to render and auto-populate `references` on save (backward compat).',
|
|
1688
1713
|
schema: {
|
|
1689
|
-
|
|
1690
|
-
|
|
1691
|
-
target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the link points AT.'),
|
|
1692
|
-
target_node_id: z.string().optional().describe('Optional 8-char hex nodeId for paragraph-level targeting. When provided, clicking the link scrolls to that paragraph in the target doc.'),
|
|
1693
|
-
quote: z.string().optional().describe('Optional text snippet for scroll-anchor fallback when target_node_id has drifted (e.g. paragraph was rewritten).'),
|
|
1714
|
+
source_doc_id: z.string().describe('Source document docId (8-char hex from list_documents). The doc declaring the connection.'),
|
|
1715
|
+
target_doc_id: z.string().describe('Target document docId (8-char hex from list_documents or search_docs). The doc the connection points AT.'),
|
|
1694
1716
|
},
|
|
1695
|
-
handler: async ({
|
|
1717
|
+
handler: async ({ source_doc_id, target_doc_id }) => {
|
|
1696
1718
|
const sourceFilename = resolveDocId(source_doc_id);
|
|
1697
1719
|
if (!sourceFilename) {
|
|
1698
1720
|
return { content: [{ type: 'text', text: `source_doc_id "${source_doc_id}" not found. Use list_documents to find the right docId.` }] };
|
|
1699
1721
|
}
|
|
1700
|
-
|
|
1701
|
-
|
|
1702
|
-
|
|
1703
|
-
|
|
1704
|
-
|
|
1705
|
-
|
|
1706
|
-
|
|
1707
|
-
// Load
|
|
1708
|
-
// otherwise. Explicit source dispatch prevents the active-doc race where
|
|
1709
|
-
// a user click in the browser silently changes which doc gets edited.
|
|
1722
|
+
const targetFilename = resolveDocId(target_doc_id);
|
|
1723
|
+
if (!targetFilename) {
|
|
1724
|
+
return { content: [{ type: 'text', text: `target_doc_id "${target_doc_id}" not found. Use list_documents or search_docs to find the right docId.` }] };
|
|
1725
|
+
}
|
|
1726
|
+
if (source_doc_id === target_doc_id) {
|
|
1727
|
+
return { content: [{ type: 'text', text: `Cannot link a document to itself (source_doc_id and target_doc_id are both "${source_doc_id}").` }] };
|
|
1728
|
+
}
|
|
1729
|
+
// Load source's current references (active doc → in-memory; otherwise → disk).
|
|
1710
1730
|
const sourceIsActive = sourceFilename === getActiveFilename();
|
|
1711
|
-
let
|
|
1731
|
+
let currentReferences;
|
|
1712
1732
|
if (sourceIsActive) {
|
|
1713
|
-
|
|
1733
|
+
const meta = getMetadata();
|
|
1734
|
+
currentReferences = Array.isArray(meta?.references) ? [...meta.references] : [];
|
|
1714
1735
|
}
|
|
1715
1736
|
else {
|
|
1716
|
-
const
|
|
1717
|
-
|
|
1718
|
-
sourceDoc = cached.document;
|
|
1719
|
-
}
|
|
1720
|
-
else {
|
|
1721
|
-
try {
|
|
1722
|
-
const raw = readFileSync(resolveDocPath(sourceFilename), 'utf-8');
|
|
1723
|
-
sourceDoc = markdownToTiptap(raw).document;
|
|
1724
|
-
}
|
|
1725
|
-
catch (err) {
|
|
1726
|
-
return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}": ${err.message}` }] };
|
|
1727
|
-
}
|
|
1728
|
-
}
|
|
1737
|
+
const fm = readFrontmatter(sourceFilename);
|
|
1738
|
+
currentReferences = Array.isArray(fm?.data?.references) ? [...fm.data.references] : [];
|
|
1729
1739
|
}
|
|
1730
|
-
//
|
|
1731
|
-
|
|
1732
|
-
|
|
1733
|
-
|
|
1734
|
-
|
|
1735
|
-
|
|
1736
|
-
|
|
1737
|
-
|
|
1738
|
-
|
|
1739
|
-
for (const child of nodeContent) {
|
|
1740
|
-
if (child.type !== 'text' || !child.text)
|
|
1741
|
-
continue;
|
|
1742
|
-
const marks = child.marks || [];
|
|
1743
|
-
const hasMatchingLink = marks.some((m) => m.type === 'link' && m.attrs?.href === href);
|
|
1744
|
-
if (hasMatchingLink)
|
|
1745
|
-
linkedText += child.text;
|
|
1746
|
-
}
|
|
1747
|
-
return linkedText.includes(text);
|
|
1740
|
+
// Idempotent: target already declared → no-op.
|
|
1741
|
+
if (currentReferences.includes(target_doc_id)) {
|
|
1742
|
+
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1743
|
+
success: true,
|
|
1744
|
+
sourceDocId: source_doc_id,
|
|
1745
|
+
targetDocId: target_doc_id,
|
|
1746
|
+
alreadyReferenced: true,
|
|
1747
|
+
references: currentReferences,
|
|
1748
|
+
}) }] };
|
|
1748
1749
|
}
|
|
1749
|
-
|
|
1750
|
-
|
|
1751
|
-
|
|
1752
|
-
|
|
1753
|
-
|
|
1754
|
-
|
|
1755
|
-
|
|
1756
|
-
|
|
1757
|
-
if (node.attrs?.id && blockText.includes(text)) {
|
|
1758
|
-
totalOccurrences++;
|
|
1759
|
-
if (isTextAlreadyLinked(node.content)) {
|
|
1760
|
-
alreadyLinkedOccurrences++;
|
|
1761
|
-
walk(node.content);
|
|
1762
|
-
continue;
|
|
1763
|
-
}
|
|
1764
|
-
sourceNodeId = node.attrs.id;
|
|
1765
|
-
return;
|
|
1766
|
-
}
|
|
1767
|
-
walk(node.content);
|
|
1768
|
-
}
|
|
1769
|
-
}
|
|
1750
|
+
// Append + dedup via Set round-trip.
|
|
1751
|
+
const newReferences = Array.from(new Set([...currentReferences, target_doc_id]));
|
|
1752
|
+
if (sourceIsActive) {
|
|
1753
|
+
// Active doc: mutate state.metadata and let save() persist the frontmatter.
|
|
1754
|
+
// save()'s writeToDisk path invalidates the backlinks cache.
|
|
1755
|
+
setMetadata({ references: newReferences });
|
|
1756
|
+
save();
|
|
1757
|
+
broadcastMetadataChanged(getMetadata());
|
|
1770
1758
|
}
|
|
1771
|
-
|
|
1772
|
-
|
|
1773
|
-
|
|
1774
|
-
|
|
1759
|
+
else {
|
|
1760
|
+
// Non-active doc: write frontmatter directly, preserving body verbatim.
|
|
1761
|
+
const fm = readFrontmatter(sourceFilename);
|
|
1762
|
+
if (!fm) {
|
|
1763
|
+
return { content: [{ type: 'text', text: `Failed to read source doc "${source_doc_id}" frontmatter.` }] };
|
|
1775
1764
|
}
|
|
1776
|
-
|
|
1777
|
-
|
|
1778
|
-
|
|
1779
|
-
|
|
1780
|
-
// happens to be foregrounded in the browser.
|
|
1781
|
-
const editResult = sourceIsActive
|
|
1782
|
-
? applyTextEdits(sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }])
|
|
1783
|
-
: applyTextEditsToFile(sourceFilename, sourceNodeId, [{ find: text, addMark: { type: 'link', attrs: { href } } }]);
|
|
1784
|
-
if (!editResult.success) {
|
|
1785
|
-
return { content: [{ type: 'text', text: `Failed to apply link mark: ${editResult.error}` }] };
|
|
1765
|
+
const merged = { ...fm.data, references: newReferences };
|
|
1766
|
+
writeFrontmatter(sourceFilename, merged);
|
|
1767
|
+
invalidateDocCache(resolveDocPath(sourceFilename));
|
|
1768
|
+
invalidateBacklinksCache();
|
|
1786
1769
|
}
|
|
1787
|
-
if (sourceIsActive)
|
|
1788
|
-
save(); // triggers writeToDisk → backlinks pipeline updates target's frontmatter
|
|
1789
1770
|
return { content: [{ type: 'text', text: JSON.stringify({
|
|
1790
1771
|
success: true,
|
|
1791
1772
|
sourceDocId: source_doc_id,
|
|
1792
|
-
|
|
1793
|
-
|
|
1794
|
-
href,
|
|
1795
|
-
...(totalOccurrences > 1 ? { remainingUnlinked: totalOccurrences - alreadyLinkedOccurrences - 1 } : {}),
|
|
1773
|
+
targetDocId: target_doc_id,
|
|
1774
|
+
references: newReferences,
|
|
1796
1775
|
}) }] };
|
|
1797
1776
|
},
|
|
1798
1777
|
},
|
|
@@ -1811,20 +1790,18 @@ export const TOOL_REGISTRY = [
|
|
|
1811
1790
|
const enriched = raw.slice(0, cap).map((r) => {
|
|
1812
1791
|
let docId = null;
|
|
1813
1792
|
let logline;
|
|
1814
|
-
let
|
|
1815
|
-
let docRole;
|
|
1793
|
+
let status;
|
|
1816
1794
|
let tags;
|
|
1817
1795
|
try {
|
|
1818
1796
|
const filePath = resolveDocPath(r.filename);
|
|
1819
1797
|
const fileRaw = readFileSync(filePath, 'utf-8');
|
|
1820
1798
|
const fm = matter(fileRaw);
|
|
1821
1799
|
docId = fm.data?.docId || null;
|
|
1800
|
+
// v0.19.0 three-field schema: logline (LLM), status (agent), tags.
|
|
1822
1801
|
if (typeof fm.data?.logline === 'string')
|
|
1823
1802
|
logline = fm.data.logline;
|
|
1824
|
-
if (typeof fm.data?.
|
|
1825
|
-
|
|
1826
|
-
if (typeof fm.data?.docRole === 'string')
|
|
1827
|
-
docRole = fm.data.docRole;
|
|
1803
|
+
if (typeof fm.data?.status === 'string')
|
|
1804
|
+
status = fm.data.status;
|
|
1828
1805
|
if (Array.isArray(fm.data?.tags) && fm.data.tags.length > 0)
|
|
1829
1806
|
tags = fm.data.tags;
|
|
1830
1807
|
}
|
|
@@ -1837,8 +1814,7 @@ export const TOOL_REGISTRY = [
|
|
|
1837
1814
|
snippet: r.snippet,
|
|
1838
1815
|
matchedTag: r.matchedTag,
|
|
1839
1816
|
...(logline ? { logline } : {}),
|
|
1840
|
-
...(
|
|
1841
|
-
...(docRole ? { docRole } : {}),
|
|
1817
|
+
...(status ? { status } : {}),
|
|
1842
1818
|
...(tags ? { tags } : {}),
|
|
1843
1819
|
};
|
|
1844
1820
|
});
|
|
@@ -1847,7 +1823,7 @@ export const TOOL_REGISTRY = [
|
|
|
1847
1823
|
},
|
|
1848
1824
|
{
|
|
1849
1825
|
name: 'get_graph',
|
|
1850
|
-
description: 'Return forward
|
|
1826
|
+
description: 'Return forward + inbound connections for a doc — the crawl primitive for cross-doc context retrieval. Forward connections read from the doc\'s `references:` frontmatter (structural data, v0.20). Inbound computed live by scanning every doc\'s references for entries pointing at this one (no stored derived field). Optional depth walks neighbors recursively (cap 3). v0.20.0 breaking change: edges are doc-to-doc; per-paragraph granularity (from_node/to_node/anchor text) was dropped — that was an artifact of the prose-link model.',
|
|
1851
1827
|
schema: {
|
|
1852
1828
|
docId: z.string().describe('Center docId for the graph walk (8-char hex).'),
|
|
1853
1829
|
depth: z.number().optional().describe('Hops to walk outward (default 1, max 3). depth=1 returns just the center\'s links; depth=2 also includes neighbors\' links.'),
|
|
@@ -1867,28 +1843,18 @@ export const TOOL_REGISTRY = [
|
|
|
1867
1843
|
catch {
|
|
1868
1844
|
return;
|
|
1869
1845
|
}
|
|
1870
|
-
const forward =
|
|
1871
|
-
const backlinks =
|
|
1846
|
+
const forward = Array.isArray(target.metadata.references) ? target.metadata.references : [];
|
|
1847
|
+
const backlinks = computeBacklinksFor(id);
|
|
1872
1848
|
nodes.push({
|
|
1873
1849
|
docId: id,
|
|
1874
1850
|
title: target.title,
|
|
1875
|
-
forward: forward.map((
|
|
1876
|
-
|
|
1877
|
-
from_node: l.from_node,
|
|
1878
|
-
to_doc: l.to_doc,
|
|
1879
|
-
...(l.to_node ? { to_node: l.to_node } : {}),
|
|
1880
|
-
})),
|
|
1881
|
-
backlinks: backlinks.map((b) => ({
|
|
1882
|
-
text: b.text,
|
|
1883
|
-
from_doc: b.from_doc,
|
|
1884
|
-
from_node: b.from_node,
|
|
1885
|
-
...(b.to_node ? { to_node: b.to_node } : {}),
|
|
1886
|
-
})),
|
|
1851
|
+
forward: forward.map((to_doc) => ({ to_doc })),
|
|
1852
|
+
backlinks: backlinks.map((b) => ({ from_doc: b.from_doc })),
|
|
1887
1853
|
});
|
|
1888
1854
|
if (hopsLeft > 0) {
|
|
1889
1855
|
const neighbors = new Set();
|
|
1890
|
-
for (const
|
|
1891
|
-
neighbors.add(
|
|
1856
|
+
for (const to_doc of forward)
|
|
1857
|
+
neighbors.add(to_doc);
|
|
1892
1858
|
for (const b of backlinks)
|
|
1893
1859
|
neighbors.add(b.from_doc);
|
|
1894
1860
|
for (const n of neighbors) {
|
package/dist/server/state.js
CHANGED
|
@@ -10,7 +10,7 @@ import { tiptapToMarkdown, tiptapToMarkdownChecked, tiptapToBody, markdownToTipt
|
|
|
10
10
|
import { applyTextEditsToNode } from './text-edit.js';
|
|
11
11
|
import { getDataDir, TEMP_PREFIX, ensureDataDir, filePathForTitle, tempFilePath, generateNodeId, LEAF_BLOCK_TYPES, resolveDocPath, isExternalDoc, atomicWriteFileSync, canonicalizePath, canonicalizeIdentifier } from './helpers.js';
|
|
12
12
|
import { snapshotIfNeeded, ensureDocId } from './versions.js';
|
|
13
|
-
import {
|
|
13
|
+
import { syncReferencesFromProse, invalidateBacklinksCache, writeFrontmatter } from './backlinks.js';
|
|
14
14
|
import { isAutoAcceptInheritedForDoc } from './workspaces.js';
|
|
15
15
|
import { matchNodes } from './node-matcher.js';
|
|
16
16
|
import { tiptapToBlocks, applyIdsToTiptap } from './node-blocks.js';
|
|
@@ -434,6 +434,15 @@ function stripLegacyAgentCreated(metadata) {
|
|
|
434
434
|
if (metadata && 'agentCreated' in metadata)
|
|
435
435
|
delete metadata.agentCreated;
|
|
436
436
|
}
|
|
437
|
+
/** Strip the legacy `backlinks` field. v0.19 stored derived inbound edges in
|
|
438
|
+
* frontmatter; v0.20 computes them live from the inverse of `references`
|
|
439
|
+
* across the workspace. Any save that visits a doc with the stale field drops
|
|
440
|
+
* it — lazy migration. No data loss because the new `references` field on
|
|
441
|
+
* source docs is the authoritative inbound source; we can always recompute. */
|
|
442
|
+
function stripLegacyBacklinks(metadata) {
|
|
443
|
+
if (metadata && 'backlinks' in metadata)
|
|
444
|
+
delete metadata.backlinks;
|
|
445
|
+
}
|
|
437
446
|
function persistExternalDocs() {
|
|
438
447
|
try {
|
|
439
448
|
atomicWriteFileSync(getExternalDocsFile(), JSON.stringify([...externalDocs]));
|
|
@@ -1990,16 +1999,6 @@ function writeToDisk() {
|
|
|
1990
1999
|
return;
|
|
1991
2000
|
}
|
|
1992
2001
|
ensureDataDir();
|
|
1993
|
-
// Capture old forward links BEFORE we overwrite the file — needed by the
|
|
1994
|
-
// backlinks engine to know which target docs to refresh when source changes.
|
|
1995
|
-
// Skip for external docs (they don't participate in the doc graph).
|
|
1996
|
-
let oldForwardLinks = [];
|
|
1997
|
-
if (!isExternalDoc(state.filePath) && state.docId) {
|
|
1998
|
-
try {
|
|
1999
|
-
oldForwardLinks = extractForwardLinksFromDisk(state.filePath, state.docId);
|
|
2000
|
-
}
|
|
2001
|
-
catch { /* best-effort */ }
|
|
2002
|
-
}
|
|
2003
2002
|
// Stub graduation: once the doc contains accepted content, it's no longer
|
|
2004
2003
|
// a fresh stub. Remove it from the in-memory stub registry so reject-all
|
|
2005
2004
|
// can never trigger the cleanup-delete on it.
|
|
@@ -2009,8 +2008,10 @@ function writeToDisk() {
|
|
|
2009
2008
|
}
|
|
2010
2009
|
// Defensive: never serialize `agentCreated` to disk. The field is dead;
|
|
2011
2010
|
// any code reading it would be the bug, not the field's presence.
|
|
2012
|
-
if (state.metadata)
|
|
2011
|
+
if (state.metadata) {
|
|
2013
2012
|
stripLegacyAgentCreated(state.metadata);
|
|
2013
|
+
stripLegacyBacklinks(state.metadata);
|
|
2014
|
+
}
|
|
2014
2015
|
let markdown;
|
|
2015
2016
|
if (isExternalDoc(state.filePath)) {
|
|
2016
2017
|
// External files: preserve original frontmatter verbatim, no OpenWriter metadata injected.
|
|
@@ -2204,16 +2205,27 @@ function writeToDisk() {
|
|
|
2204
2205
|
snapshotIfNeeded(state.docId, state.filePath);
|
|
2205
2206
|
}
|
|
2206
2207
|
catch { /* ignore */ }
|
|
2207
|
-
//
|
|
2208
|
-
//
|
|
2208
|
+
// Auto-sync references from prose: legacy `doc:` prose links still render
|
|
2209
|
+
// (PadLink extension), but the graph/crawl/backlinks-panel read the
|
|
2210
|
+
// structural `references:` field. After every save, scan the body for
|
|
2211
|
+
// prose links and merge their targets into references — backward compat
|
|
2212
|
+
// without forcing rewrites. Then invalidate the live-backlinks cache so
|
|
2213
|
+
// the next /api/backlinks/:docId call sees the fresh inverse.
|
|
2214
|
+
// Best-effort — never blocks the save it follows.
|
|
2209
2215
|
if (!isExternalDoc(state.filePath) && state.docId) {
|
|
2210
2216
|
try {
|
|
2211
|
-
const
|
|
2212
|
-
|
|
2217
|
+
const sync = syncReferencesFromProse(state.docId, state.document, state.metadata || {});
|
|
2218
|
+
if (sync && state.metadata) {
|
|
2219
|
+
state.metadata.references = sync.newReferences;
|
|
2220
|
+
// Second tiny write: re-persist frontmatter only (body already on disk).
|
|
2221
|
+
const filename = state.filePath.split(/[/\\]/).pop() || '';
|
|
2222
|
+
writeFrontmatter(filename, state.metadata);
|
|
2223
|
+
}
|
|
2213
2224
|
}
|
|
2214
2225
|
catch (err) {
|
|
2215
|
-
console.error('[State]
|
|
2226
|
+
console.error('[State] references auto-sync failed:', err);
|
|
2216
2227
|
}
|
|
2228
|
+
invalidateBacklinksCache();
|
|
2217
2229
|
}
|
|
2218
2230
|
}
|
|
2219
2231
|
export function save() {
|
|
@@ -2756,6 +2768,28 @@ export function populateDocumentFile(filename, doc) {
|
|
|
2756
2768
|
if (!isAutoAcceptActive(filename, parsed.metadata)) {
|
|
2757
2769
|
markAllNodesAsPending(doc, 'insert');
|
|
2758
2770
|
}
|
|
2771
|
+
// Bug #1 fix (v0.20.0): preserve the stub's trailing canonical paragraph(s).
|
|
2772
|
+
// flushDocToFile writes `doc` directly — it does NOT merge with the existing
|
|
2773
|
+
// parsed.document on disk. Without this merge step, the stub's auto-generated
|
|
2774
|
+
// trailing paragraph falls out of canonical, the matcher's `previousNodes`
|
|
2775
|
+
// for any subsequent save no longer references it, and a follow-up Accept All
|
|
2776
|
+
// doc-update can find itself with no matching previousNodes to anchor against.
|
|
2777
|
+
// Cascading: the matcher classifies the newly accepted inserts as deletions
|
|
2778
|
+
// (orphaned from the empty previousNodes set), they go to graveyard, the disk
|
|
2779
|
+
// body ends up empty.
|
|
2780
|
+
// Mirrors the active-doc fix in mcp.ts:populate_document.
|
|
2781
|
+
if (parsed.document?.content?.length) {
|
|
2782
|
+
const incomingIds = new Set(doc.content
|
|
2783
|
+
.map((n) => n?.attrs?.id)
|
|
2784
|
+
.filter((id) => typeof id === 'string'));
|
|
2785
|
+
const preserved = parsed.document.content.filter((n) => {
|
|
2786
|
+
const id = n?.attrs?.id;
|
|
2787
|
+
return id && !incomingIds.has(id);
|
|
2788
|
+
});
|
|
2789
|
+
if (preserved.length > 0) {
|
|
2790
|
+
doc.content = [...doc.content, ...preserved];
|
|
2791
|
+
}
|
|
2792
|
+
}
|
|
2759
2793
|
flushDocToFile(filename, doc, parsed.title, parsed.metadata);
|
|
2760
2794
|
const pendingCount = countPending(doc.content);
|
|
2761
2795
|
const text = extractText(doc.content);
|