orquestra-mcp 1.33.0 → 1.37.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/bin/orquestra-mcp.mjs +14 -0
- package/package.json +4 -1
- package/src/server.mjs +109 -5
- package/src/vaultSync.mjs +249 -0
package/bin/orquestra-mcp.mjs
CHANGED
|
@@ -2,6 +2,7 @@
|
|
|
2
2
|
import { StdioServerTransport } from '@modelcontextprotocol/sdk/server/stdio.js'
|
|
3
3
|
import { createServer } from '../src/server.mjs'
|
|
4
4
|
import { runSetup } from '../src/setup.mjs'
|
|
5
|
+
import { runVaultSync } from '../src/vaultSync.mjs'
|
|
5
6
|
|
|
6
7
|
const HELP_TEXT = `orquestra-mcp -- Servidor MCP de Orquestra (35 tools)
|
|
7
8
|
|
|
@@ -9,6 +10,9 @@ Uso:
|
|
|
9
10
|
npx orquestra-mcp Inicia el servidor MCP por stdio -- lo usa tu cliente
|
|
10
11
|
MCP (Claude Code, Claude Desktop, etc.), no se corre a mano
|
|
11
12
|
npx orquestra-mcp setup Login por navegador + configura tu proyecto default
|
|
13
|
+
npx orquestra-mcp vault sync <carpeta> [--apply]
|
|
14
|
+
Sincroniza la base de conocimiento del workspace con una
|
|
15
|
+
carpeta de tu vault de Obsidian (la 1ra vez solo muestra el plan)
|
|
12
16
|
npx orquestra-mcp --help Muestra esta ayuda
|
|
13
17
|
|
|
14
18
|
Config (la deja lista "setup"): ~/.orquestra/mcp-config.json
|
|
@@ -31,6 +35,16 @@ async function main() {
|
|
|
31
35
|
return
|
|
32
36
|
}
|
|
33
37
|
|
|
38
|
+
if (cmd === 'vault' && process.argv[3] === 'sync') {
|
|
39
|
+
try {
|
|
40
|
+
await runVaultSync(process.argv.slice(4))
|
|
41
|
+
} catch (err) {
|
|
42
|
+
console.error(`[orquestra-mcp] ${err.message}`)
|
|
43
|
+
process.exit(1)
|
|
44
|
+
}
|
|
45
|
+
return
|
|
46
|
+
}
|
|
47
|
+
|
|
34
48
|
let server
|
|
35
49
|
try {
|
|
36
50
|
server = createServer()
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "orquestra-mcp",
|
|
3
|
-
"version": "1.
|
|
3
|
+
"version": "1.37.0",
|
|
4
4
|
"description": "Servidor MCP de Orquestra -- 115 tools para leer y escribir tareas, ideas, changelog, infraestructura, contenido, QA y más desde un agente de IA",
|
|
5
5
|
"type": "module",
|
|
6
6
|
"bin": {
|
|
@@ -27,5 +27,8 @@
|
|
|
27
27
|
"dependencies": {
|
|
28
28
|
"@modelcontextprotocol/sdk": "^1.12.0",
|
|
29
29
|
"zod": "^4.6.5"
|
|
30
|
+
},
|
|
31
|
+
"scripts": {
|
|
32
|
+
"test": "node --test test/*.test.mjs"
|
|
30
33
|
}
|
|
31
34
|
}
|
package/src/server.mjs
CHANGED
|
@@ -102,6 +102,7 @@ Durante el trabajo normal de código, sin que el usuario te lo pida explícitame
|
|
|
102
102
|
- Orquestra es el ÚNICO lugar donde se planea y se lleva el avance del trabajo: el plan de pasos va como checklist de la tarea (add_checklist_item) y se marca con update_checklist_item conforme terminas cada uno. No lleves una lista de pendientes paralela en el chat, en archivos temporales ni en la herramienta de tareas de tu propio cliente -- se pierde al cerrar la sesión y el usuario no la ve. Si retomas trabajo en una sesión nueva, parte de la tarea y sus ítems pendientes (list_tasks + list_checklist_items), no de cero. Excepción: un cambio de un solo paso (renombrar algo, responder una pregunta) no necesita tarea.
|
|
103
103
|
- Mientras trabajas, mueve la tarea por los estatus reales según avanzas (no la dejes en el inicial hasta el final). Al terminar, muévela a su estatus final con update_task y registra una entrada de changelog con create_changelog_entry -- así el usuario nunca tiene que documentar esto a mano. Si sabes en qué repo real estás trabajando (podés correr "git remote get-url origin" o ya lo sabes por la carpeta), pásalo SIEMPRE como "repo" ("owner/name") -- encontrado real (17 ago 2026): ni el hook local ni el GitHub Action ni la reconciliación de orquestra-infra lo estaban mandando pese a que el campo existe hace rato, así que ninguna entrada automática sabía de qué repo/plataforma venía. Si además la distribución de ese repo ya está identificada (ver list_distributions), pásala en "distributions" -- sin esto el changelog no puede filtrar/agrupar por plataforma.
|
|
104
104
|
- Todo cambio de statusId vía update_task valida la transición (nextIds) y queda en el historial de la tarea -- pero ese historial no dice nada por sí solo. Manda SIEMPRE el parámetro "comment" cuando cambies statusId, explicando qué hiciste o por qué -- sin esto, el usuario ve que la tarea cambió pero no sabe por qué ni qué se hizo de verdad (encontrado real: quedaba invisible, a diferencia de cuando un humano mueve la tarjeta a mano en el kanban).
|
|
105
|
+
- Conocimiento que sirve más allá de un proyecto (guías, procesos, decisiones transversales) va en la base del workspace: revisa list_knowledge antes de documentarlo de nuevo, créalo con create_knowledge o súbelo con promote_to_knowledge; lo de un solo proyecto sigue en notas/docs.
|
|
105
106
|
- Reuniones, llamadas y seguimientos NO son tareas: son actividades (no se entregan, se programan y se registran). Usa log_activity para agendarlas o registrarlas, update_activity para marcarlas hechas con su resultado (outcome) y siguiente paso (nextStep), y list_activities para ver la agenda/bitácora -- nunca create_task con esos tipos. Si del siguiente paso sale trabajo real, eso sí es una tarea (create_task normal, con módulo y fecha).
|
|
106
107
|
- Antes de crear un módulo/estatus/categoría/servicio/entorno/distribución, revisa con el list_* correspondiente que no exista ya uno con ese nombre.
|
|
107
108
|
- Si una nota que vas a crear (create_note) es claramente sobre un módulo o una tarea específica -- por ejemplo durante un setup guiado donde acabas de crear ambos -- mándale moduleId/taskId para ligarla, en vez de dejarla suelta. No inventes la relación si no es clara.
|
|
@@ -248,7 +249,7 @@ export function createServer() {
|
|
|
248
249
|
'Busca por texto en tareas, actividades (reuniones/llamadas), notas, ideas y docs de un proyecto de Orquestra. Tope de 30 resultados con las tareas primero: si buscas ideas o docs en un proyecto con muchas tareas, pasa `types` para que no queden tapadas.',
|
|
249
250
|
{
|
|
250
251
|
q: z.string().describe('Texto a buscar'),
|
|
251
|
-
types: z.array(z.enum(['task', 'activity', 'note', 'idea', 'doc'])).optional().describe('Limitar a estos tipos (opcional). Sin esto busca en todos.'),
|
|
252
|
+
types: z.array(z.enum(['task', 'activity', 'note', 'idea', 'doc', 'knowledge'])).optional().describe('Limitar a estos tipos (opcional). Sin esto busca en todos. knowledge = solo la base vinculada a este proyecto.'),
|
|
252
253
|
},
|
|
253
254
|
(args, projectId) => ({ projectId, q: args.q, types: args.types?.join(',') })
|
|
254
255
|
)
|
|
@@ -767,6 +768,103 @@ export function createServer() {
|
|
|
767
768
|
}
|
|
768
769
|
)
|
|
769
770
|
|
|
771
|
+
// Base de conocimiento del workspace (ORQ-298) -- ver lib/knowledge.mjs en orquestra-infra.
|
|
772
|
+
registerRead(
|
|
773
|
+
'list_knowledge',
|
|
774
|
+
'/v1/knowledge',
|
|
775
|
+
'Lista la base de conocimiento del WORKSPACE (guías, procesos, decisiones que no son de un solo proyecto). Cuerpo recortado a 300 caracteres salvo full=true. linkedTo=projectId trae solo lo vinculado a ese proyecto. Revísala antes de documentar algo que quizá ya existe.',
|
|
776
|
+
{
|
|
777
|
+
q: z.string().optional().describe('Texto a buscar en título/cuerpo'),
|
|
778
|
+
tag: z.string().optional(),
|
|
779
|
+
linkedTo: z.string().optional().describe('projectId: solo entradas vinculadas a ese proyecto'),
|
|
780
|
+
full: z.boolean().optional().describe('true = cuerpo completo'),
|
|
781
|
+
},
|
|
782
|
+
(args, projectId) => ({ projectId, q: args.q, tag: args.tag, linkedTo: args.linkedTo, full: args.full ? 'true' : undefined })
|
|
783
|
+
)
|
|
784
|
+
|
|
785
|
+
server.registerTool(
|
|
786
|
+
'get_knowledge',
|
|
787
|
+
{
|
|
788
|
+
description: 'Lee una entrada completa de la base de conocimiento del workspace.',
|
|
789
|
+
inputSchema: { projectId: z.string().optional().describe(PROJECT_ID_DESC), id: z.string() },
|
|
790
|
+
},
|
|
791
|
+
async ({ projectId, id }) => {
|
|
792
|
+
try { return textResult(await apiGet(config, `/v1/knowledge/${id}`, { projectId: resolveProjectId(projectId) })) } catch (err) { return errorResult(err) }
|
|
793
|
+
}
|
|
794
|
+
)
|
|
795
|
+
|
|
796
|
+
registerCreate(
|
|
797
|
+
'create_knowledge',
|
|
798
|
+
'/v1/knowledge',
|
|
799
|
+
'Crea una entrada en la base de conocimiento del WORKSPACE (no de un proyecto). Úsala para lo reutilizable entre proyectos; lo específico de un proyecto va en create_note/create_doc. [[Título]] enlaza otra entrada de la base.',
|
|
800
|
+
{
|
|
801
|
+
title: z.string(),
|
|
802
|
+
body: z.string().optional().describe('Markdown'),
|
|
803
|
+
tags: z.array(z.string()).optional(),
|
|
804
|
+
visibility: z.enum(['workspace', 'private']).optional().describe('Default workspace. private = solo la ve quien la creó.'),
|
|
805
|
+
linkToProject: z.boolean().optional().describe('true = vincularla de una vez al proyecto actual'),
|
|
806
|
+
}
|
|
807
|
+
)
|
|
808
|
+
|
|
809
|
+
registerUpdate('update_knowledge', '/v1/knowledge',
|
|
810
|
+
'Edita una entrada de la base de conocimiento del workspace (solo los campos que mandes).',
|
|
811
|
+
{
|
|
812
|
+
title: z.string().optional(),
|
|
813
|
+
body: z.string().optional(),
|
|
814
|
+
tags: z.array(z.string()).optional().describe('Reemplaza la lista completa'),
|
|
815
|
+
visibility: z.enum(['workspace', 'private']).optional(),
|
|
816
|
+
}
|
|
817
|
+
)
|
|
818
|
+
|
|
819
|
+
server.registerTool(
|
|
820
|
+
'link_knowledge_to_project',
|
|
821
|
+
{
|
|
822
|
+
description: 'Lleva una entrada de la base a un proyecto. mode "link" (default) = referencia viva: aparece en el proyecto y entra al contexto del asistente; "unlink" la quita; "copy" crea una nota o doc independiente en el proyecto (snapshot, ya no se actualiza).',
|
|
823
|
+
inputSchema: {
|
|
824
|
+
projectId: z.string().optional().describe(PROJECT_ID_DESC),
|
|
825
|
+
id: z.string().describe('id de la entrada (ver list_knowledge)'),
|
|
826
|
+
mode: z.enum(['link', 'unlink', 'copy']).optional(),
|
|
827
|
+
as: z.enum(['note', 'doc']).optional().describe('Solo para copy. Default note.'),
|
|
828
|
+
docType: z.enum(DOC_TYPES).optional().describe('Solo para copy como doc. Default readme.'),
|
|
829
|
+
},
|
|
830
|
+
},
|
|
831
|
+
async ({ projectId, id, ...body }) => {
|
|
832
|
+
try { return textResult(await apiPost(config, `/v1/knowledge/${id}/link`, { projectId: resolveProjectId(projectId), ...body })) } catch (err) { return errorResult(err) }
|
|
833
|
+
}
|
|
834
|
+
)
|
|
835
|
+
|
|
836
|
+
registerCreate(
|
|
837
|
+
'promote_to_knowledge',
|
|
838
|
+
'/v1/knowledge/promote',
|
|
839
|
+
'Promueve una nota o doc del proyecto a la base de conocimiento del workspace (cuando sirve más allá de este proyecto). El original no se borra: queda con knowledgeId y la entrada queda vinculada a este proyecto. Solo se puede promover una vez.',
|
|
840
|
+
{
|
|
841
|
+
type: z.enum(['note', 'doc']),
|
|
842
|
+
id: z.string(),
|
|
843
|
+
tags: z.array(z.string()).optional(),
|
|
844
|
+
}
|
|
845
|
+
)
|
|
846
|
+
|
|
847
|
+
// Plantillas (ORQ-297): base (código) + propias del workspace.
|
|
848
|
+
registerRead(
|
|
849
|
+
'list_templates',
|
|
850
|
+
'/v1/templates',
|
|
851
|
+
'Plantillas de notas y docs: las base de Orquestra (minuta de reunión, retro, propuesta a cliente, checklist de lanzamiento, ADR, runbook; ids "builtin:*") y las propias del workspace. Úsalas con templateId en create_note/create_doc en vez de inventar la estructura.',
|
|
852
|
+
{ kind: z.enum(['note', 'doc']).optional() },
|
|
853
|
+
(args, projectId) => ({ projectId, kind: args.kind })
|
|
854
|
+
)
|
|
855
|
+
|
|
856
|
+
// Wikilinks (ORQ-296): backlinks calculados del texto en el server (lib/wikilinks.mjs).
|
|
857
|
+
registerRead(
|
|
858
|
+
'get_backlinks',
|
|
859
|
+
'/v1/backlinks',
|
|
860
|
+
'Backlinks ("Mencionado en"): qué notas, docs, tareas e ideas enlazan con [[...]] a la entidad pedida. Útil antes de editar o cerrar algo para ver qué depende de eso.',
|
|
861
|
+
{
|
|
862
|
+
type: z.enum(['note', 'doc', 'task', 'idea']),
|
|
863
|
+
id: z.string().describe('id de la nota/doc/tarea/idea'),
|
|
864
|
+
},
|
|
865
|
+
(args, projectId) => ({ projectId, type: args.type, id: args.id })
|
|
866
|
+
)
|
|
867
|
+
|
|
770
868
|
registerRead(
|
|
771
869
|
'list_activities',
|
|
772
870
|
'/v1/activities',
|
|
@@ -1789,7 +1887,7 @@ export function createServer() {
|
|
|
1789
1887
|
registerCreate(
|
|
1790
1888
|
'create_note',
|
|
1791
1889
|
'/v1/notes',
|
|
1792
|
-
'Crea una nota en un proyecto de Orquestra.',
|
|
1890
|
+
'Crea una nota en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
|
|
1793
1891
|
{
|
|
1794
1892
|
title: z.string().describe('Título de la nota'),
|
|
1795
1893
|
body: z.string().optional(),
|
|
@@ -1802,12 +1900,15 @@ export function createServer() {
|
|
|
1802
1900
|
visibility: z.enum(NOTE_VISIBILITY).optional().describe('Default: team. "private" restringe la lectura a quien la creó (enforcement real en firestore.rules, no solo la UI).'),
|
|
1803
1901
|
relatedNoteId: z.string().optional().describe('Otra nota relacionada, si es clara la relación (ver search) -- agregado 12 ago 2026. No inventes el vínculo si no es evidente.'),
|
|
1804
1902
|
adrDocId: z.string().optional().describe('Liga esta nota a un doc ADR ya creado con create_doc (type="adr"). No lo mandes junto con publishAsAdr -- ese genera su propio doc.'),
|
|
1903
|
+
templateId: z.string().optional().describe('Plantilla (ver list_templates): rellena el texto si no mandas uno, y el tipo si no lo mandas. Las variables {{fecha}}/{{proyecto}}/{{autor}}/{{titulo}} se resuelven solas.'),
|
|
1904
|
+
templateVars: z.object({ fecha: z.string().optional(), proyecto: z.string().optional(), autor: z.string().optional(), titulo: z.string().optional() }).optional()
|
|
1905
|
+
.describe('Sobrescribe variables de la plantilla -- útil para {{fecha}} en la hora local del usuario.'),
|
|
1805
1906
|
publishAsAdr: z.boolean().optional().describe('Solo para type=\'decision\'. Genera automáticamente el doc ADR correspondiente (numerado "ADR-XXX: {title}", con la plantilla Contexto/Alternativas consideradas/Decisión/Consecuencias a partir de body/alternativesConsidered/wouldRevertIf) y liga esta nota a él -- mismo flujo que el toggle "Publicar como ADR" de NewNoteModal.jsx en la web, en un solo paso.'),
|
|
1806
1907
|
}
|
|
1807
1908
|
)
|
|
1808
1909
|
|
|
1809
1910
|
registerUpdate('update_note', '/v1/notes',
|
|
1810
|
-
'Edita una nota existente. Todos los campos son opcionales -- solo se cambian los que mandes.',
|
|
1911
|
+
'Edita una nota existente. Todos los campos son opcionales -- solo se cambian los que mandes. Si cambias el título, los [[Título viejo]] de todo el proyecto se reescriben solos.',
|
|
1811
1912
|
{
|
|
1812
1913
|
title: z.string().optional(),
|
|
1813
1914
|
body: z.string().optional(),
|
|
@@ -1861,11 +1962,14 @@ export function createServer() {
|
|
|
1861
1962
|
registerCreate(
|
|
1862
1963
|
'create_doc',
|
|
1863
1964
|
'/v1/docs',
|
|
1864
|
-
'Crea un documento en un proyecto de Orquestra.',
|
|
1965
|
+
'Crea un documento en un proyecto de Orquestra. En el texto, [[Título]] o [[Título|texto]] enlaza otra nota, doc, tarea o idea del proyecto (o [[ORQ-123]] por folio) -- úsalo para conectar el conocimiento en vez de pegar ids.',
|
|
1865
1966
|
{
|
|
1866
|
-
type: z.enum(DOC_TYPES).describe('Tipo de documento'),
|
|
1967
|
+
type: z.enum(DOC_TYPES).optional().describe('Tipo de documento -- obligatorio salvo que mandes templateId (la plantilla trae su tipo)'),
|
|
1867
1968
|
title: z.string().describe('Título del documento'),
|
|
1868
1969
|
content: z.string().optional(),
|
|
1970
|
+
templateId: z.string().optional().describe('Plantilla (ver list_templates): rellena el texto si no mandas uno, y el tipo si no lo mandas. Las variables {{fecha}}/{{proyecto}}/{{autor}}/{{titulo}} se resuelven solas.'),
|
|
1971
|
+
templateVars: z.object({ fecha: z.string().optional(), proyecto: z.string().optional(), autor: z.string().optional(), titulo: z.string().optional() }).optional()
|
|
1972
|
+
.describe('Sobrescribe variables de la plantilla -- útil para {{fecha}} en la hora local del usuario.'),
|
|
1869
1973
|
adrStatus: z.enum(ADR_STATUSES).optional().describe('Solo para type=\'adr\'. "aceptado" es lo único que alimenta Material (Contenido) como fuente de ángulos -- ver adrContext.'),
|
|
1870
1974
|
adrContext: z.string().optional().describe('Solo para type=\'adr\'. Qué problema/situación llevó a la decisión, en texto estructurado (no el markdown libre de content).'),
|
|
1871
1975
|
adrConsequences: z.string().optional().describe('Solo para type=\'adr\'. Qué cambia de ahora en adelante.'),
|
|
@@ -0,0 +1,249 @@
|
|
|
1
|
+
// Sincronización con un vault de Obsidian (ORQ-299): `orquestra-mcp vault sync <carpeta>`.
|
|
2
|
+
//
|
|
3
|
+
// Sincroniza la BASE DE CONOCIMIENTO DEL WORKSPACE (ORQ-298, /v1/knowledge) con
|
|
4
|
+
// una carpeta del vault, en los dos sentidos. Obsidian queda como editor y
|
|
5
|
+
// Orquestra como respaldo, búsqueda y contexto de IA. Cada entrada es un .md
|
|
6
|
+
// cuyo NOMBRE es el título (así [[Título]] resuelve igual en Obsidian y en
|
|
7
|
+
// Orquestra) con frontmatter:
|
|
8
|
+
// orquestraId, orquestraUpdatedAt, orquestraTitle (si el nombre tuvo que
|
|
9
|
+
// sanearse), tags. Cualquier otra clave del frontmatter se conserva.
|
|
10
|
+
// `.orquestra-sync.json` en la carpeta guarda lo último sincronizado de cada
|
|
11
|
+
// lado (updatedAt remoto + hash local) para saber quién cambió.
|
|
12
|
+
// Conflicto (cambiaron los dos): gana el más reciente y el otro lado queda
|
|
13
|
+
// como "<nombre>.conflict.md" junto al archivo. Nada se borra nunca: una
|
|
14
|
+
// entrada borrada en el vault se vuelve a bajar, y un .md con orquestraId que
|
|
15
|
+
// ya no existe en Orquestra solo se reporta.
|
|
16
|
+
// Fuera de alcance v1: adjuntos/imágenes del vault (se reportan, no se suben).
|
|
17
|
+
import { createHash } from 'node:crypto'
|
|
18
|
+
import { promises as fs } from 'node:fs'
|
|
19
|
+
import path from 'node:path'
|
|
20
|
+
import { loadConfig, apiGet, apiPost, apiPatch } from './api.mjs'
|
|
21
|
+
|
|
22
|
+
export const STATE_FILE = '.orquestra-sync.json'
|
|
23
|
+
const CONFLICT_SUFFIX = '.conflict.md'
|
|
24
|
+
|
|
25
|
+
// ── Formato ─────────────────────────────────────────────────────────────────
|
|
26
|
+
|
|
27
|
+
export function sanitizeFileName(title) {
|
|
28
|
+
const clean = (title || 'Sin título').replace(/[\\/:*?"<>|#^[\]]/g, '-').replace(/\s+/g, ' ').trim().slice(0, 120)
|
|
29
|
+
return clean || 'Sin título'
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
const unquote = v => v.replace(/^["']|["']$/g, '')
|
|
33
|
+
|
|
34
|
+
/** { meta: {orquestraId, orquestraUpdatedAt, orquestraTitle, tags}, extra: [líneas], body } */
|
|
35
|
+
export function parseNote(text) {
|
|
36
|
+
const meta = { tags: [] }
|
|
37
|
+
const extra = []
|
|
38
|
+
let body = text
|
|
39
|
+
const m = text.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/)
|
|
40
|
+
if (m) {
|
|
41
|
+
body = text.slice(m[0].length)
|
|
42
|
+
const lines = m[1].split(/\r?\n/)
|
|
43
|
+
for (let i = 0; i < lines.length; i++) {
|
|
44
|
+
const line = lines[i]
|
|
45
|
+
const kv = line.match(/^([A-Za-z][\w-]*):\s*(.*)$/)
|
|
46
|
+
if (!kv) { extra.push(line); continue }
|
|
47
|
+
const [, key, raw] = kv
|
|
48
|
+
if (key === 'tags') {
|
|
49
|
+
if (raw.startsWith('[')) {
|
|
50
|
+
meta.tags = raw.replace(/^\[|\]$/g, '').split(',').map(t => unquote(t.trim())).filter(Boolean)
|
|
51
|
+
} else if (raw.trim()) {
|
|
52
|
+
meta.tags = raw.split(/[,\s]+/).map(t => unquote(t.trim())).filter(Boolean)
|
|
53
|
+
} else {
|
|
54
|
+
while (lines[i + 1]?.match(/^\s*-\s+/)) meta.tags.push(unquote(lines[++i].replace(/^\s*-\s+/, '').trim()))
|
|
55
|
+
}
|
|
56
|
+
} else if (key === 'orquestraId' || key === 'orquestraTitle') {
|
|
57
|
+
meta[key] = unquote(raw.trim())
|
|
58
|
+
} else if (key === 'orquestraUpdatedAt') {
|
|
59
|
+
meta.orquestraUpdatedAt = Number(raw.trim()) || 0
|
|
60
|
+
} else {
|
|
61
|
+
extra.push(line)
|
|
62
|
+
}
|
|
63
|
+
}
|
|
64
|
+
}
|
|
65
|
+
meta.tags = meta.tags.map(t => t.replace(/^#/, '').toLowerCase())
|
|
66
|
+
return { meta, extra, body }
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
export function serializeNote({ id, updatedAt, title, fileTitle, tags = [], body = '', extra = [] }) {
|
|
70
|
+
const lines = ['---']
|
|
71
|
+
if (id) lines.push(`orquestraId: ${id}`)
|
|
72
|
+
if (updatedAt) lines.push(`orquestraUpdatedAt: ${updatedAt}`)
|
|
73
|
+
if (title && title !== fileTitle) lines.push(`orquestraTitle: ${JSON.stringify(title)}`)
|
|
74
|
+
lines.push(`tags: [${tags.join(', ')}]`)
|
|
75
|
+
lines.push(...extra)
|
|
76
|
+
lines.push('---')
|
|
77
|
+
return `${lines.join('\n')}\n${body}`
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
/** Título real de un archivo: el de Orquestra si el nombre es solo su versión saneada, si no el nombre (lo renombraron). */
|
|
81
|
+
export function titleOfFile(fileTitle, meta) {
|
|
82
|
+
return meta.orquestraTitle && sanitizeFileName(meta.orquestraTitle) === fileTitle ? meta.orquestraTitle : fileTitle
|
|
83
|
+
}
|
|
84
|
+
|
|
85
|
+
export function contentHash({ title, body, tags }) {
|
|
86
|
+
return createHash('sha1').update(`${title}\n${[...(tags || [])].sort().join(',')}\n${(body || '').replace(/\r\n/g, '\n').trimEnd()}`).digest('hex')
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
// ── Plan (puro, sin red ni disco) ───────────────────────────────────────────
|
|
90
|
+
|
|
91
|
+
/**
|
|
92
|
+
* remote: [{id, title, body, tags, updatedAt}]
|
|
93
|
+
* local: [{file, title, body, tags, id, mtimeMs, hash, extra}]
|
|
94
|
+
* state: {entries: {[id]: {file, remoteUpdatedAt, localHash}}}
|
|
95
|
+
* → [{op, ...}] con op: pull | push | create | conflict | orphan
|
|
96
|
+
*/
|
|
97
|
+
export function planSync({ remote, local, state }) {
|
|
98
|
+
const entries = state?.entries || {}
|
|
99
|
+
const localById = new Map(local.filter(l => l.id).map(l => [l.id, l]))
|
|
100
|
+
const remoteIds = new Set(remote.map(r => r.id))
|
|
101
|
+
const plan = []
|
|
102
|
+
|
|
103
|
+
for (const r of remote) {
|
|
104
|
+
const l = localById.get(r.id)
|
|
105
|
+
const prev = entries[r.id]
|
|
106
|
+
if (!l) { plan.push({ op: 'pull', remote: r, reason: prev ? 'borrada en el vault, se restaura' : 'nueva en Orquestra' }); continue }
|
|
107
|
+
if (!prev) {
|
|
108
|
+
// Vinculada pero sin historial (state perdido): si difieren, conflicto; si no, nada.
|
|
109
|
+
if (contentHash(r) !== l.hash) plan.push({ op: 'conflict', remote: r, local: l, winner: (l.mtimeMs || 0) > (r.updatedAt || 0) ? 'local' : 'remote' })
|
|
110
|
+
continue
|
|
111
|
+
}
|
|
112
|
+
const remoteChanged = (r.updatedAt || 0) > (prev.remoteUpdatedAt || 0)
|
|
113
|
+
const localChanged = l.hash !== prev.localHash
|
|
114
|
+
if (remoteChanged && localChanged) {
|
|
115
|
+
plan.push({ op: 'conflict', remote: r, local: l, winner: (l.mtimeMs || 0) > (r.updatedAt || 0) ? 'local' : 'remote' })
|
|
116
|
+
} else if (remoteChanged) {
|
|
117
|
+
plan.push({ op: 'pull', remote: r, local: l, reason: 'cambió en Orquestra' })
|
|
118
|
+
} else if (localChanged) {
|
|
119
|
+
plan.push({ op: 'push', remote: r, local: l })
|
|
120
|
+
}
|
|
121
|
+
}
|
|
122
|
+
for (const l of local) {
|
|
123
|
+
if (!l.id) plan.push({ op: 'create', local: l })
|
|
124
|
+
else if (!remoteIds.has(l.id)) plan.push({ op: 'orphan', local: l })
|
|
125
|
+
}
|
|
126
|
+
return plan
|
|
127
|
+
}
|
|
128
|
+
|
|
129
|
+
// ── Disco ────────────────────────────────────────────────────────────────────
|
|
130
|
+
|
|
131
|
+
async function scanVault(dir, rel = '') {
|
|
132
|
+
const out = []
|
|
133
|
+
for (const ent of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
|
|
134
|
+
if (ent.name.startsWith('.')) continue
|
|
135
|
+
const relPath = path.join(rel, ent.name)
|
|
136
|
+
if (ent.isDirectory()) out.push(...await scanVault(dir, relPath))
|
|
137
|
+
else if (ent.name.endsWith('.md') && !ent.name.endsWith(CONFLICT_SUFFIX)) out.push(relPath)
|
|
138
|
+
}
|
|
139
|
+
return out
|
|
140
|
+
}
|
|
141
|
+
|
|
142
|
+
async function readLocal(dir) {
|
|
143
|
+
const files = await scanVault(dir)
|
|
144
|
+
const notes = []
|
|
145
|
+
const attachments = []
|
|
146
|
+
for (const file of files) {
|
|
147
|
+
const full = path.join(dir, file)
|
|
148
|
+
const [text, stat] = await Promise.all([fs.readFile(full, 'utf8'), fs.stat(full)])
|
|
149
|
+
const { meta, extra, body } = parseNote(text)
|
|
150
|
+
const fileTitle = path.basename(file, '.md')
|
|
151
|
+
const title = titleOfFile(fileTitle, meta)
|
|
152
|
+
const note = { file, title, body, tags: meta.tags, id: meta.orquestraId || null, mtimeMs: stat.mtimeMs, extra }
|
|
153
|
+
note.hash = contentHash(note)
|
|
154
|
+
notes.push(note)
|
|
155
|
+
for (const emb of body.matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:)([^)]+)\)/g)) attachments.push({ file, ref: emb[1] || emb[2] })
|
|
156
|
+
}
|
|
157
|
+
return { notes, attachments }
|
|
158
|
+
}
|
|
159
|
+
|
|
160
|
+
async function readState(dir) {
|
|
161
|
+
try { return JSON.parse(await fs.readFile(path.join(dir, STATE_FILE), 'utf8')) } catch { return null }
|
|
162
|
+
}
|
|
163
|
+
|
|
164
|
+
function uniqueFile(title, taken) {
|
|
165
|
+
const base = sanitizeFileName(title)
|
|
166
|
+
let name = `${base}.md`
|
|
167
|
+
for (let n = 2; taken.has(name.toLowerCase()); n++) name = `${base} (${n}).md`
|
|
168
|
+
taken.add(name.toLowerCase())
|
|
169
|
+
return name
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
// ── Comando ──────────────────────────────────────────────────────────────────
|
|
173
|
+
|
|
174
|
+
const LABEL = { pull: '↓ bajar', push: '↑ subir', create: '↑ crear', conflict: '⚠ conflicto', orphan: '? sin entrada en Orquestra' }
|
|
175
|
+
|
|
176
|
+
export async function runVaultSync(argv) {
|
|
177
|
+
const dirArg = argv.find(a => !a.startsWith('--'))
|
|
178
|
+
if (!dirArg) {
|
|
179
|
+
console.log('Uso: orquestra-mcp vault sync <carpeta-del-vault> [--apply | --dry-run]\n' +
|
|
180
|
+
'Sincroniza la base de conocimiento del workspace con esa carpeta. Córrelo desde la carpeta de un\n' +
|
|
181
|
+
'proyecto ya conectado con "orquestra-mcp setup" (de ahí salen la cuenta y el workspace).')
|
|
182
|
+
return
|
|
183
|
+
}
|
|
184
|
+
const dir = path.resolve(dirArg)
|
|
185
|
+
await fs.mkdir(dir, { recursive: true })
|
|
186
|
+
const config = loadConfig()
|
|
187
|
+
const projectId = config.defaultProjectId
|
|
188
|
+
if (!projectId) throw new Error('Esta carpeta no está conectada a un proyecto de Orquestra -- corre "npx orquestra-mcp setup" primero.')
|
|
189
|
+
|
|
190
|
+
const state = await readState(dir)
|
|
191
|
+
if (state && state.workspaceId !== config.workspaceId) {
|
|
192
|
+
throw new Error(`Esta carpeta ya se sincroniza con otro workspace (${state.workspaceId}). Usa otra carpeta.`)
|
|
193
|
+
}
|
|
194
|
+
// Primera vez: solo muestra el plan, salvo --apply explícito.
|
|
195
|
+
const apply = argv.includes('--apply') || (!!state && !argv.includes('--dry-run'))
|
|
196
|
+
|
|
197
|
+
const { items: remote } = await apiGet(config, '/v1/knowledge', { projectId, full: 'true' })
|
|
198
|
+
const { notes: local, attachments } = await readLocal(dir)
|
|
199
|
+
const plan = planSync({ remote, local, state })
|
|
200
|
+
|
|
201
|
+
console.log(`Vault: ${dir}\nOrquestra: ${remote.length} entradas · Vault: ${local.length} notas · ${plan.length} cambios${apply ? '' : ' (vista previa, no se escribe nada)'}`)
|
|
202
|
+
for (const p of plan) console.log(` ${LABEL[p.op]} ${p.local?.file || p.remote?.title}${p.reason ? ` (${p.reason})` : ''}${p.op === 'conflict' ? ` -- gana ${p.winner === 'local' ? 'el vault' : 'Orquestra'}` : ''}`)
|
|
203
|
+
if (attachments.length) console.log(` ${attachments.length} imagen(es)/adjunto(s) del vault no se sincronizan en esta versión (solo texto).`)
|
|
204
|
+
if (!apply) {
|
|
205
|
+
if (plan.length) console.log('\nPara aplicar: orquestra-mcp vault sync ' + dirArg + ' --apply')
|
|
206
|
+
return
|
|
207
|
+
}
|
|
208
|
+
|
|
209
|
+
const entries = { ...(state?.entries || {}) }
|
|
210
|
+
const taken = new Set(local.map(l => path.basename(l.file).toLowerCase()))
|
|
211
|
+
const writeRemote = async (r, file, extra = []) => {
|
|
212
|
+
const fileTitle = path.basename(file, '.md')
|
|
213
|
+
await fs.mkdir(path.dirname(path.join(dir, file)), { recursive: true })
|
|
214
|
+
await fs.writeFile(path.join(dir, file), serializeNote({ id: r.id, updatedAt: r.updatedAt, title: r.title, fileTitle, tags: r.tags || [], body: r.body || '', extra }))
|
|
215
|
+
entries[r.id] = { file, remoteUpdatedAt: r.updatedAt || 0, localHash: contentHash({ title: r.title, body: r.body, tags: r.tags || [] }) }
|
|
216
|
+
}
|
|
217
|
+
const push = async (l, id) => {
|
|
218
|
+
const body = { projectId, title: l.title, body: l.body, tags: l.tags }
|
|
219
|
+
const saved = id ? await apiPatch(config, '/v1/knowledge', { id, ...body }) : await apiPost(config, '/v1/knowledge', body)
|
|
220
|
+
// Reescribe el frontmatter con el id/updatedAt nuevos, sin tocar el texto.
|
|
221
|
+
await writeRemote({ ...saved, title: l.title, body: l.body, tags: saved.tags || l.tags }, l.file, l.extra)
|
|
222
|
+
}
|
|
223
|
+
const saveConflictCopy = async (file, text) => {
|
|
224
|
+
await fs.writeFile(path.join(dir, file.replace(/\.md$/, CONFLICT_SUFFIX)), text)
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
let failed = 0
|
|
228
|
+
for (const p of plan) {
|
|
229
|
+
try {
|
|
230
|
+
if (p.op === 'pull') await writeRemote(p.remote, p.local?.file || entries[p.remote.id]?.file || uniqueFile(p.remote.title, taken), p.local?.extra)
|
|
231
|
+
else if (p.op === 'push') await push(p.local, p.remote.id)
|
|
232
|
+
else if (p.op === 'create') await push(p.local, null)
|
|
233
|
+
else if (p.op === 'conflict') {
|
|
234
|
+
if (p.winner === 'local') {
|
|
235
|
+
await saveConflictCopy(p.local.file, serializeNote({ id: p.remote.id, title: p.remote.title, fileTitle: '', tags: p.remote.tags || [], body: p.remote.body || '' }))
|
|
236
|
+
await push(p.local, p.remote.id)
|
|
237
|
+
} else {
|
|
238
|
+
await saveConflictCopy(p.local.file, await fs.readFile(path.join(dir, p.local.file), 'utf8'))
|
|
239
|
+
await writeRemote(p.remote, p.local.file, p.local.extra)
|
|
240
|
+
}
|
|
241
|
+
}
|
|
242
|
+
} catch (err) {
|
|
243
|
+
failed++
|
|
244
|
+
console.error(` ✗ ${p.local?.file || p.remote?.title}: ${err.message}`)
|
|
245
|
+
}
|
|
246
|
+
}
|
|
247
|
+
await fs.writeFile(path.join(dir, STATE_FILE), JSON.stringify({ workspaceId: config.workspaceId, syncedAt: Date.now(), entries }, null, 2))
|
|
248
|
+
console.log(failed ? `\nListo con ${failed} error(es) -- vuelve a correrlo para reintentar.` : '\nListo.')
|
|
249
|
+
}
|