orquestra-mcp 1.55.0 → 1.58.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.
@@ -15,8 +15,10 @@ Uso:
15
15
  Lo mismo para una herramienta puntual (claude-code, codex,
16
16
  cursor, gemini-cli...): cada una firma con su propio agente
17
17
  npx orquestra-mcp vault sync <carpeta> [--apply]
18
- Sincroniza la base de conocimiento del workspace con una
19
- carpeta de tu vault de Obsidian (la 1ra vez solo muestra el plan)
18
+ Sincroniza la base de conocimiento del workspace (notas e
19
+ imágenes/videos) con una carpeta de tu vault de Obsidian.
20
+ La 1ra vez solo muestra el plan; --help lista las opciones
21
+ (--dry-run, --no-attachments, --verbose)
20
22
  npx orquestra-mcp --help Muestra esta ayuda
21
23
 
22
24
  Config (la deja lista "setup"): ~/.orquestra/mcp-config.json
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "orquestra-mcp",
3
- "version": "1.55.0",
3
+ "version": "1.58.0",
4
4
  "description": "Servidor MCP de Orquestra -- 135 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": {
package/src/server.mjs CHANGED
@@ -284,7 +284,7 @@ export function createServer() {
284
284
  registerRead(
285
285
  'list_tasks',
286
286
  '/v1/tasks',
287
- 'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus/módulo/tipo/prioridad/responsable/tag. Los filtros se combinan con AND -- pásalos en vez de traer todo el proyecto y filtrar tú mismo del lado del cliente. Por default excluye tareas cerradas (isFinal/isCancel/isArchive) -- un proyecto con historial acumula cientos, traerlas todas revienta el contexto sin que nadie las haya pedido. Si mandas statusId puntual, eso ya es una elección explícita y no se le suma este filtro (podés pedir "solo Listo"). Para historial/auditoría real, usa includeClosed=true. No incluye actividades (reuniones/llamadas/seguimientos) -- esas van con list_activities.',
287
+ 'Lista las tareas de un proyecto de Orquestra, opcionalmente filtradas por estatus/módulo/tipo/prioridad/responsable/tag/groupKey (código de agrupación, ej. V2). Cada tarea trae su groupCode si lo tiene. Los filtros se combinan con AND -- pásalos en vez de traer todo el proyecto y filtrar tú mismo del lado del cliente. Por default excluye tareas cerradas (isFinal/isCancel/isArchive) -- un proyecto con historial acumula cientos, traerlas todas revienta el contexto sin que nadie las haya pedido. Si mandas statusId puntual, eso ya es una elección explícita y no se le suma este filtro (podés pedir "solo Listo"). Para historial/auditoría real, usa includeClosed=true. No incluye actividades (reuniones/llamadas/seguimientos) -- esas van con list_activities.',
288
288
  {
289
289
  statusId: z.string().optional().describe('ID de estatus para filtrar (opcional) -- pedir un estatus puntual (incluso uno cerrado) siempre se respeta tal cual, sin el filtro default de abiertas'),
290
290
  moduleId: z.string().optional().describe('ID de módulo para filtrar (opcional)'),
@@ -293,6 +293,7 @@ export function createServer() {
293
293
  assignedTo: z.string().optional().describe('uid del responsable para filtrar (opcional)'),
294
294
  tag: z.string().optional().describe('Un tag exacto para filtrar -- solo uno, no una lista (opcional)'),
295
295
  workCategory: z.enum(WORK_CATEGORIES).optional().describe('Categoría de trabajo para filtrar (opcional): plan | build | delivery | review'),
296
+ groupKey: z.string().max(10).optional().describe('Clave de agrupación exacta (ej. "V2") para traer solo las tareas de ese grupo (groupCode V2-1, V2-2...) -- úsalo antes de crear una tarea nueva de una iniciativa para reutilizar la clave y ver cuáles existen (opcional)'),
296
297
  includeClosed: z.boolean().optional().describe('Default false -- sin esto, tareas en un estatus isFinal/isCancel/isArchive quedan afuera. Ponelo en true solo si de verdad necesitás el historial cerrado (auditoría, "¿cuándo se hizo X?").'),
297
298
  },
298
299
  (args, projectId) => ({
@@ -304,6 +305,7 @@ export function createServer() {
304
305
  assignedTo: args.assignedTo,
305
306
  tag: args.tag,
306
307
  workCategory: args.workCategory,
308
+ groupKey: args.groupKey,
307
309
  includeClosed: args.includeClosed,
308
310
  })
309
311
  )
@@ -788,6 +790,7 @@ export function createServer() {
788
790
  skipParity: z.boolean().optional().describe('true = NO sumar las plataformas hermanas al scope aunque tengan el mismo productKey. Solo cuando el trabajo es genuinamente de UNA plataforma (ej. un ajuste de Gradle que no tiene contraparte en iOS) -- no lo mandes por default. Las tareas type="bug" nunca propagan, sin necesidad de esto.'),
789
791
  parityExempt: z.boolean().optional().describe('true = este trabajo es de UNA sola plataforma y no cuenta en get_parity_summary (se pone solo cuando skipParity va con scope de una sola dist). false para volver a contarla.'),
790
792
  parityOf: z.string().optional().describe('Folio: id de la tarea ORIGEN de la que esta es la hermana para otra plataforma (ej. la versión iOS de una tarea de Android). En paridad cuentan como UN item y al cerrar esta, su plataforma queda hecha en el origen. El origen no puede ser hermana de otra. "" lo quita.'),
793
+ groupKey: z.string().max(10).optional().describe('Código de agrupación (ORQ-464): clave corta de una iniciativa que cruza módulos (ej. "V2", "LANZ"; 2-10 letras/números, empieza con letra). El server asigna groupCode = clave + número ("V2-3"), separado del moduleCode y del folio ORQ-N. Úsalo cuando la tarea es parte de una iniciativa/entrega mayor que atraviesa varios módulos; propónlo tú al armar ese trabajo. Reutiliza la MISMA clave para todas las tareas del mismo grupo y revisa con list_tasks (groupKey) las que ya existen antes de inventar una nueva. No uses la clave de un módulo (400). Sin groupKey la tarea no lleva grupo.'),
791
794
  moduleCode: z.string().max(40).optional().describe('Clave de la tarea dentro de su módulo (ej. "AUTH-3"), ADEMÁS del folio ORQ-N. NO la mandes normalmente: si el módulo tiene clave (key), el server la asigna sola. Solo para conservar la clave que ya traía de otro sistema al importar (ej. "PROJ-128" de Jira/Linear).'),
792
795
  relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea. LEGACY desde ORQ-248: si vas a bloquear por más de una tarea, usa blockedByIds en vez de esto (relatedTaskId se sigue llenando solo, como blockedByIds[0], para paridad con iOS/Android).'),
793
796
  blockedByIds: z.array(z.string()).optional().describe('"Bloqueada por" -- ids de otras tareas (puede ser más de una). Esta tarea se muestra como bloqueada mientras ALGUNA de esas no llegue a un estatus final (isFinal). Reemplaza a relatedTaskId para bloqueos múltiples -- preferí este campo, no relatedTaskId, salvo que de verdad sea un solo bloqueador.'),
@@ -836,6 +839,7 @@ export function createServer() {
836
839
  .describe('Alcance por distribución -- usa get_parity_summary para ver el scope actual antes de editarlo'),
837
840
  parityExempt: z.boolean().optional().describe('true = este trabajo es de UNA sola plataforma y no cuenta en get_parity_summary; false para volver a contarla.'),
838
841
  parityOf: z.string().optional().describe('Folio: id de la tarea ORIGEN de la que esta es la hermana para otra plataforma (ej. la versión iOS de una tarea de Android). En paridad cuentan como UN item y al cerrar esta, su plataforma queda hecha en el origen. El origen no puede ser hermana de otra. "" lo quita.'),
842
+ groupKey: z.string().max(10).optional().describe('Código de agrupación (ORQ-464): clave corta de una iniciativa que cruza módulos (ej. "V2"; 2-10 letras/números, empieza con letra). Estable: editar la tarea sin groupKey NO cambia su groupCode ni se gasta número si mandas la misma clave. Otra clave la reasigna (número nuevo de esa clave, ej. "LANZ-1"); "" quita el código. Reutiliza la misma clave para las tareas del mismo grupo y revisa antes con list_tasks (groupKey). No uses la clave de un módulo (400).'),
839
843
  moduleCode: z.string().max(40).optional().describe('Clave de la tarea dentro de su módulo (ej. "AUTH-3"), texto libre editable a mano. "" la quita. Mover la tarea a otro módulo (moduleId) NO la cambia: la clave es estable.'),
840
844
  relatedTaskId: z.string().optional().describe('"Bloqueada por" -- id de otra tarea, o "" para quitar el bloqueo. LEGACY, ver create_task -- preferí blockedByIds.'),
841
845
  blockedByIds: z.array(z.string()).optional().describe('"Bloqueada por" -- reemplaza el array COMPLETO (no hace merge), [] quita todos los bloqueos. Ver create_task.'),
@@ -872,8 +876,8 @@ export function createServer() {
872
876
  location: z.string().optional().describe('Plataforma o lugar ("Zoom", "Oficina del cliente") o contexto libre para un seguimiento.'),
873
877
  participants: z.array(z.string()).optional().describe('Nombres libres de quienes participan (cliente, equipo) -- no uids.'),
874
878
  description: z.string().optional().describe('Notas previas.'),
875
- agendaItems: z.array(z.union([z.string(), z.object({ id: z.string().optional(), text: z.string(), group: z.string().max(60).optional(), createdAt: z.number().optional(), createdBy: z.string().optional() })])).optional()
876
- .describe('Puntos a revisar en la reunión (ej. "Revisar [[ORQ-123]]"). Lista simple, NO checklist. Cada punto puede llevar group (agrupa puntos bajo un encabezado, máx. 60 caracteres). Reemplaza la lista COMPLETA: para agregar uno, lee la actual con list_activities y manda todos, con sus id para conservarlos Y con su group -- si reenvías un punto sin group, pierde su grupo.'),
879
+ agendaItems: z.array(z.union([z.string(), z.object({ id: z.string().optional(), text: z.string(), group: z.string().max(60).optional(), resolution: z.string().max(2000).optional(), resolutionStatus: z.enum(['pendiente', 'decidido', 'pospuesto', '']).optional(), createdAt: z.number().optional(), createdBy: z.string().optional() })])).optional()
880
+ .describe('Puntos a revisar en la reunión (ej. "Revisar [[ORQ-123]]"). Lista simple, NO checklist. Cada punto puede llevar group (agrupa puntos bajo un encabezado, máx. 60 caracteres). Reemplaza la lista COMPLETA: para agregar uno, lee la actual con list_activities y manda todos, con sus id para conservarlos Y con su group -- si reenvías un punto sin group, pierde su grupo. Cada punto puede llevar además resolution (qué se decidió, máx. 2000 caracteres) y resolutionStatus ("pendiente" | "decidido" | "pospuesto"): son informativos, NO bloquean el cierre de la actividad. Si reenvías un punto con su id sin esos campos, el server conserva los guardados; manda "" para borrarlos. list_activities los devuelve en cada punto.'),
877
881
  outcome: z.string().optional().describe('Resultado / minuta corta -- qué se acordó. Llénalo al marcarla hecha.'),
878
882
  nextStep: z.string().optional().describe('Siguiente paso acordado. Si es trabajo real, además crea la tarea con create_task y liga su id en nextStepTaskId.'),
879
883
  nextStepTaskId: z.string().optional().describe('id de la tarea creada desde nextStep.'),
@@ -1041,7 +1045,7 @@ export function createServer() {
1041
1045
  registerRead(
1042
1046
  'list_activities',
1043
1047
  '/v1/activities',
1044
- 'Lista las actividades (reuniones, llamadas, seguimientos) de un proyecto de Orquestra. status "scheduled" = agenda (próximas primero), "done" = bitácora (más recientes primero); sin status trae todas. from/to acotan por día.',
1048
+ 'Lista las actividades (reuniones, llamadas, seguimientos) de un proyecto de Orquestra. status "scheduled" = agenda (próximas primero), "done" = bitácora (más recientes primero); sin status trae todas. from/to acotan por día. Cada punto de agendaItems puede traer resolution y resolutionStatus (pendiente/decidido/pospuesto).',
1045
1049
  {
1046
1050
  status: z.enum(ACTIVITY_STATUSES).optional(),
1047
1051
  type: z.enum(ACTIVITY_TYPES).optional(),
@@ -0,0 +1,190 @@
1
+ // Imágenes y videos del vault de Obsidian (ORQ-299): se suben a S3 como adjuntos de la
2
+ // entrada de conocimiento (`entityType: 'knowledge'`, /v1/attachments/upload-url +
3
+ // POST /v1/knowledge/{id}/attachments) y se bajan de vuelta al vault en otra máquina.
4
+ //
5
+ // El texto de la nota NO se reescribe: `![[foto.png]]` / `![](img/foto.png)` quedan tal cual,
6
+ // porque el adjunto guarda como `fileName` la ruta del archivo relativa al vault. Así un
7
+ // `vault sync` en otra Mac vuelve a poner cada archivo donde Obsidian lo espera.
8
+ //
9
+ // Reglas (iguales a las del adjunto normal, `add_attachment`): png/jpg/jpeg/gif/webp hasta
10
+ // 15 MB y mp4/mov/webm hasta 500 MB. Cualquier otro tipo (pdf, audio, zip...) se reporta y no
11
+ // se sincroniza. Nunca se borra un archivo del vault; solo se reemplaza en S3 la versión
12
+ // anterior de un archivo que cambió.
13
+ import { createHash } from 'node:crypto'
14
+ import { promises as fs } from 'node:fs'
15
+ import path from 'node:path'
16
+ import { apiGet, apiPost, apiDelete, putToPresignedUrl } from './api.mjs'
17
+
18
+ export const IMAGE_MIME = { png: 'image/png', jpg: 'image/jpeg', jpeg: 'image/jpeg', gif: 'image/gif', webp: 'image/webp' }
19
+ export const VIDEO_MIME = { mp4: 'video/mp4', mov: 'video/quicktime', webm: 'video/webm' }
20
+ export const MAX_IMAGE_BYTES = 15 * 1024 * 1024
21
+ export const MAX_VIDEO_BYTES = 500 * 1024 * 1024
22
+
23
+ const toPosix = p => p.split(path.sep).join('/')
24
+ const extOf = p => path.extname(p).slice(1).toLowerCase()
25
+
26
+ // ── Referencias (puro) ──────────────────────────────────────────────────────
27
+
28
+ /** Referencias a archivos incrustados en el texto de una nota: `![[x|alias]]` y `![](ruta)` (no URLs). */
29
+ export function embedRefs(body) {
30
+ const refs = []
31
+ for (const m of (body || '').matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:|data:)([^)]+)\)/g)) {
32
+ let ref = m[1]
33
+ if (ref !== undefined) {
34
+ ref = ref.split('|')[0].split('#')[0].trim()
35
+ } else {
36
+ ref = m[2].trim().replace(/^<|>$/g, '').replace(/\s+"[^"]*"$/, '')
37
+ try { ref = decodeURIComponent(ref) } catch { /* se queda como venía */ }
38
+ ref = ref.split('#')[0]
39
+ }
40
+ if (ref) refs.push(ref)
41
+ }
42
+ return [...new Set(refs)]
43
+ }
44
+
45
+ /** Índice de archivos del vault para resolver referencias: { all: Set<rel>, byBase: Map<lowerBasename, rel[]> }. */
46
+ export function buildFileIndex(relPaths) {
47
+ const all = new Set(relPaths)
48
+ const byBase = new Map()
49
+ for (const rel of relPaths) {
50
+ const key = path.posix.basename(rel).toLowerCase()
51
+ byBase.set(key, [...(byBase.get(key) || []), rel].sort())
52
+ }
53
+ return { all, byBase }
54
+ }
55
+
56
+ /**
57
+ * Ruta (relativa al vault) del archivo al que apunta `ref`, o null si no existe.
58
+ * Mismo orden que Obsidian: ruta desde la raíz del vault, ruta relativa a la nota y, por último,
59
+ * solo el nombre (el más cercano a la nota si hay varios con el mismo nombre).
60
+ */
61
+ export function resolveRef(ref, noteFile, index) {
62
+ const clean = ref.replace(/\\/g, '/').replace(/^\/+/, '')
63
+ if (index.all.has(clean)) return clean
64
+ const near = path.posix.normalize(path.posix.join(path.posix.dirname(toPosix(noteFile)), clean))
65
+ if (!near.startsWith('..') && index.all.has(near)) return near
66
+ const candidates = index.byBase.get(path.posix.basename(clean).toLowerCase())
67
+ if (!candidates?.length) return null
68
+ const dir = path.posix.dirname(toPosix(noteFile))
69
+ return candidates.find(c => path.posix.dirname(c) === dir) || candidates[0]
70
+ }
71
+
72
+ /** ¿Se sincroniza este archivo? → { ok, kind, mime } o { ok:false, reason: 'unsupported' | 'toolarge' }. */
73
+ export function classifyFile(rel, size) {
74
+ const ext = extOf(rel)
75
+ const image = IMAGE_MIME[ext]
76
+ const video = VIDEO_MIME[ext]
77
+ if (!image && !video) return { ok: false, reason: 'unsupported' }
78
+ const max = image ? MAX_IMAGE_BYTES : MAX_VIDEO_BYTES
79
+ if (size > max) return { ok: false, reason: 'toolarge', maxMb: max / 1024 / 1024 }
80
+ return { ok: true, kind: image ? 'image' : 'video', mime: image || video }
81
+ }
82
+
83
+ /** Seguro para escribir dentro del vault: relativa, sin `..` ni rutas absolutas. */
84
+ export function isSafeRelPath(rel) {
85
+ if (!rel || path.isAbsolute(rel) || /^[a-zA-Z]:/.test(rel)) return false
86
+ const norm = path.posix.normalize(String(rel).replace(/\\/g, '/'))
87
+ return !norm.startsWith('..') && !norm.startsWith('/') && !norm.split('/').includes('.git')
88
+ }
89
+
90
+ // ── Plan de subida (puro) ───────────────────────────────────────────────────
91
+
92
+ /**
93
+ * notes: [{file, id|null, title, body}]
94
+ * files: Map<rel, {size, hash}> (solo hace falta de los archivos referenciados; ver `fileInfoFor`)
95
+ * index: salida de buildFileIndex
96
+ * attState: { [entryId]: { [rel]: {attachmentId, hash} } }
97
+ * → [{ note, rel, status, ... }] con status:
98
+ * upload (nuevo) | replace (cambió) | unchanged | missing | unsupported | toolarge
99
+ * Una misma imagen usada en dos notas se sube una vez por nota (cada entrada tiene sus adjuntos).
100
+ */
101
+ export function planAttachmentUploads({ notes, files, index, attState = {} }) {
102
+ const out = []
103
+ for (const note of notes) {
104
+ for (const ref of embedRefs(note.body)) {
105
+ const ext = extOf(ref)
106
+ // `![[Otra nota]]` (transclusión) no tiene extensión de archivo: no es un adjunto.
107
+ if (!ext || ext === 'md') continue
108
+ const rel = resolveRef(ref, note.file, index)
109
+ if (!rel) { out.push({ note, ref, rel: null, status: 'missing' }); continue }
110
+ const info = files.get(rel)
111
+ const cls = classifyFile(rel, info?.size ?? 0)
112
+ if (!cls.ok) { out.push({ note, ref, rel, status: cls.reason, size: info?.size, maxMb: cls.maxMb }); continue }
113
+ const prev = note.id ? attState[note.id]?.[rel] : null
114
+ const status = !prev ? 'upload' : prev.hash === info.hash ? 'unchanged' : 'replace'
115
+ out.push({ note, ref, rel, status, size: info.size, hash: info.hash, kind: cls.kind, mime: cls.mime, prev })
116
+ }
117
+ }
118
+ return out
119
+ }
120
+
121
+ // ── Disco ────────────────────────────────────────────────────────────────────
122
+
123
+ /** Todos los archivos NO .md del vault (rutas relativas, posix). Las carpetas/archivos que empiezan con "." se saltan (.obsidian). */
124
+ export async function scanVaultFiles(dir, rel = '') {
125
+ const out = []
126
+ for (const ent of await fs.readdir(path.join(dir, rel), { withFileTypes: true })) {
127
+ if (ent.name.startsWith('.')) continue
128
+ const relPath = path.join(rel, ent.name)
129
+ if (ent.isDirectory()) out.push(...await scanVaultFiles(dir, relPath))
130
+ else if (!ent.name.endsWith('.md')) out.push(toPosix(relPath))
131
+ }
132
+ return out
133
+ }
134
+
135
+ /** Tamaño y hash de contenido de los archivos referenciados (solo los que existen). */
136
+ export async function fileInfoFor(dir, rels) {
137
+ const info = new Map()
138
+ for (const rel of new Set(rels)) {
139
+ try {
140
+ const buf = await fs.readFile(path.join(dir, rel))
141
+ info.set(rel, { size: buf.length, hash: createHash('sha1').update(buf).digest('hex') })
142
+ } catch { /* desaparecido entre el escaneo y la lectura: queda como missing */ }
143
+ }
144
+ return info
145
+ }
146
+
147
+ // ── Red ──────────────────────────────────────────────────────────────────────
148
+
149
+ /** Adjuntos de una entrada en Orquestra, uno por ruta (si hay repetidos gana el más reciente). */
150
+ export async function listRemoteAttachments(config, projectId, entryId) {
151
+ const { items } = await apiGet(config, `/v1/knowledge/${entryId}/attachments`, { projectId })
152
+ const byPath = new Map()
153
+ for (const a of items || []) {
154
+ if ((a.kind !== 'image' && a.kind !== 'video') || !a.key || !a.fileName) continue
155
+ const prev = byPath.get(a.fileName)
156
+ if (!prev || (a.createdAt || 0) > (prev.createdAt || 0)) byPath.set(a.fileName, a)
157
+ }
158
+ return byPath
159
+ }
160
+
161
+ /** Sube un archivo del vault como adjunto de la entrada; si reemplaza uno anterior, borra la versión vieja. */
162
+ export async function uploadAttachment(config, projectId, entryId, dir, item) {
163
+ const buffer = await fs.readFile(path.join(dir, item.rel))
164
+ const { uploadUrl, key } = await apiPost(config, '/v1/attachments/upload-url', {
165
+ projectId, entityType: 'knowledge', entityId: entryId,
166
+ fileName: item.rel, fileType: item.mime, fileSize: item.size,
167
+ })
168
+ await putToPresignedUrl(uploadUrl, buffer, item.mime)
169
+ const created = await apiPost(config, `/v1/knowledge/${entryId}/attachments`, {
170
+ projectId, kind: item.kind, key, fileName: item.rel, fileType: item.mime, fileSize: item.size,
171
+ })
172
+ if (item.prev?.attachmentId) {
173
+ // Es la versión anterior del MISMO archivo (el id sale de nuestro estado): la nueva ya quedó guardada.
174
+ await apiDelete(config, `/v1/knowledge/${entryId}/attachments`, { projectId, id: item.prev.attachmentId }).catch(() => {})
175
+ }
176
+ return { attachmentId: created.id, hash: item.hash, size: item.size }
177
+ }
178
+
179
+ /** Baja un adjunto al vault SIN pisar un archivo que ya exista. Devuelve true si lo escribió. */
180
+ export async function downloadAttachment(config, projectId, dir, attachment) {
181
+ if (!isSafeRelPath(attachment.fileName)) throw new Error(`ruta no segura en Orquestra: ${attachment.fileName}`)
182
+ const target = path.join(dir, attachment.fileName)
183
+ if (await fs.access(target).then(() => true, () => false)) return false
184
+ const { url } = await apiGet(config, '/v1/attachments/view-url', { projectId, key: attachment.key })
185
+ const res = await fetch(url, { signal: AbortSignal.timeout(120_000) })
186
+ if (!res.ok) throw new Error(`HTTP ${res.status} al bajar ${attachment.fileName}`)
187
+ await fs.mkdir(path.dirname(target), { recursive: true })
188
+ await fs.writeFile(target, Buffer.from(await res.arrayBuffer()))
189
+ return true
190
+ }
package/src/vaultSync.mjs CHANGED
@@ -13,11 +13,16 @@
13
13
  // como "<nombre>.conflict.md" junto al archivo. Nada se borra nunca: una
14
14
  // entrada borrada en el vault se vuelve a bajar, y un .md con orquestraId que
15
15
  // ya no existe en Orquestra solo se reporta.
16
- // Fuera de alcance v1: adjuntos/imágenes del vault (se reportan, no se suben).
16
+ // Imágenes y videos del vault (ORQ-299): se suben a S3 como adjuntos de la entrada y se bajan
17
+ // de vuelta en otra máquina -- ver src/vaultAttachments.mjs. `--no-attachments` los salta.
17
18
  import { createHash } from 'node:crypto'
18
19
  import { promises as fs } from 'node:fs'
19
20
  import path from 'node:path'
20
21
  import { loadConfig, apiGet, apiPost, apiPatch } from './api.mjs'
22
+ import {
23
+ buildFileIndex, planAttachmentUploads, scanVaultFiles, fileInfoFor, embedRefs, resolveRef,
24
+ listRemoteAttachments, uploadAttachment, downloadAttachment, isSafeRelPath,
25
+ } from './vaultAttachments.mjs'
21
26
 
22
27
  export const STATE_FILE = '.orquestra-sync.json'
23
28
  const CONFLICT_SUFFIX = '.conflict.md'
@@ -142,7 +147,6 @@ async function scanVault(dir, rel = '') {
142
147
  async function readLocal(dir) {
143
148
  const files = await scanVault(dir)
144
149
  const notes = []
145
- const attachments = []
146
150
  for (const file of files) {
147
151
  const full = path.join(dir, file)
148
152
  const [text, stat] = await Promise.all([fs.readFile(full, 'utf8'), fs.stat(full)])
@@ -152,9 +156,8 @@ async function readLocal(dir) {
152
156
  const note = { file, title, body, tags: meta.tags, id: meta.orquestraId || null, mtimeMs: stat.mtimeMs, extra }
153
157
  note.hash = contentHash(note)
154
158
  notes.push(note)
155
- for (const emb of body.matchAll(/!\[\[([^\]]+)\]\]|!\[[^\]]*\]\((?!https?:)([^)]+)\)/g)) attachments.push({ file, ref: emb[1] || emb[2] })
156
159
  }
157
- return { notes, attachments }
160
+ return { notes }
158
161
  }
159
162
 
160
163
  async function readState(dir) {
@@ -169,18 +172,154 @@ function uniqueFile(title, taken) {
169
172
  return name
170
173
  }
171
174
 
175
+
176
+ // ── Reporte (puro) ───────────────────────────────────────────────────────────
177
+
178
+ const LABEL = {
179
+ pull: '↓ Bajar de Orquestra al vault',
180
+ push: '↑ Subir del vault a Orquestra',
181
+ create: '↑ Crear en Orquestra (notas nuevas del vault)',
182
+ conflict: '⚠ Conflictos (cambiaron en los dos lados)',
183
+ orphan: '? Sin entrada en Orquestra',
184
+ }
185
+ const OP_ORDER = ['pull', 'push', 'create', 'conflict', 'orphan']
186
+ const DONE_LABEL = { pull: '↓ bajada', push: '↑ subida', create: '↑ creada', conflict: '⚠ conflicto resuelto', orphan: '? sin entrada' }
187
+
188
+ export function formatBytes(n) {
189
+ if (n >= 1024 * 1024) return `${(n / 1024 / 1024).toFixed(1)} MB`
190
+ if (n >= 1024) return `${Math.round(n / 1024)} KB`
191
+ return `${n} B`
192
+ }
193
+
194
+ export function ago(ms, now = Date.now()) {
195
+ const min = Math.floor((now - ms) / 60000)
196
+ if (min < 1) return 'hace un momento'
197
+ if (min < 60) return `hace ${min} min`
198
+ const h = Math.round(min / 60)
199
+ if (h < 24) return `hace ${h} h`
200
+ return `hace ${Math.round(h / 24)} d`
201
+ }
202
+
203
+ const itemName = p => p.local?.file || p.remote?.title
204
+
205
+ /**
206
+ * Texto del reporte antes de aplicar (o la vista previa). Todo lo que decide el usuario está aquí:
207
+ * qué pasa con cada nota y cada archivo, por qué, y el siguiente paso.
208
+ * att: { enabled, uploads:[{rel,title,size,status}], downloads:[{rel,title,size?}], unchanged:n,
209
+ * missing:[{rel,title}], unsupported:[{rel,title}], toolarge:[{rel,title,size,maxMb}] }
210
+ */
211
+ export function renderReport({ dir, dirArg, apply, firstRun, lastSyncAt, remoteCount, localCount, plan, att, verbose = false, now = Date.now() }) {
212
+ const out = []
213
+ out.push(`Vault: ${dir}`)
214
+ out.push(`Orquestra: ${remoteCount} entradas · Vault: ${localCount} notas · ${firstRun ? 'Primera sincronización' : `Última sincronización ${ago(lastSyncAt, now)}`}`)
215
+ out.push(apply ? 'Modo: APLICAR CAMBIOS' : 'Modo: VISTA PREVIA -- no se escribe nada todavía')
216
+ if (firstRun) {
217
+ out.push('', 'Cómo funciona:',
218
+ ' • Cada nota del vault es una entrada de la base de conocimiento de tu workspace (el nombre del archivo es el título).',
219
+ ' • Al sincronizar se añade un bloque de propiedades (frontmatter) con orquestraId; no toques esas líneas.',
220
+ ' • NUNCA se borra nada: una nota que borres del vault se vuelve a bajar, y si cambia en los dos lados se guarda una copia .conflict.md.')
221
+ }
222
+
223
+ out.push('', `NOTAS -- ${plan.length === 0 ? 'todo al día' : `${plan.length} cambio${plan.length === 1 ? '' : 's'}`}`)
224
+ for (const op of OP_ORDER) {
225
+ const items = plan.filter(p => p.op === op)
226
+ if (!items.length) continue
227
+ out.push(` ${LABEL[op]} (${items.length})`)
228
+ for (const p of items) {
229
+ const why = op === 'conflict' ? `gana ${p.winner === 'local' ? 'la versión del vault' : 'la de Orquestra'}; la otra queda como .conflict.md` : p.reason
230
+ out.push(` • ${itemName(p)}${why ? ` -- ${why}` : ''}`)
231
+ }
232
+ if (op === 'orphan') out.push(' (tiene orquestraId pero esa entrada ya no existe en Orquestra; no se toca. Para crearla de nuevo, borra la línea orquestraId del archivo.)')
233
+ }
234
+
235
+ if (att.enabled) {
236
+ const nothing = !att.uploads.length && !att.downloads.length && !att.missing.length && !att.unsupported.length && !att.toolarge.length
237
+ out.push('', `IMÁGENES Y VIDEOS -- ${nothing ? (att.unchanged ? `${att.unchanged} ya sincronizados` : 'ninguno referenciado en las notas') : 'cambios'}`)
238
+ if (att.uploads.length) {
239
+ const bytes = att.uploads.reduce((a, u) => a + (u.size || 0), 0)
240
+ out.push(` ↑ Subir a Orquestra (${att.uploads.length}, ${formatBytes(bytes)})`)
241
+ for (const u of att.uploads) out.push(` • ${u.rel} (${formatBytes(u.size || 0)}, en "${u.title}"${u.status === 'replace' ? ', cambió: se reemplaza la versión anterior' : ''})`)
242
+ }
243
+ if (att.downloads.length) {
244
+ out.push(` ↓ Bajar al vault (${att.downloads.length})`)
245
+ for (const d of att.downloads) out.push(` • ${d.rel} (en "${d.title}")`)
246
+ }
247
+ if (att.unchanged && !nothing) out.push(` ✓ ${att.unchanged} ya sincronizados`)
248
+ if (att.missing.length) {
249
+ out.push(` ✗ Referenciados pero no existen en el vault (${att.missing.length}) -- revisa el nombre o la ruta`)
250
+ for (const m of att.missing) out.push(` • ${m.rel || m.ref} (en "${m.title}")`)
251
+ }
252
+ if (att.unsupported.length) {
253
+ out.push(` – No se sincronizan (${att.unsupported.length}): solo png/jpg/gif/webp y mp4/mov/webm`)
254
+ for (const m of att.unsupported) out.push(` • ${m.rel} (en "${m.title}")`)
255
+ }
256
+ if (att.toolarge.length) {
257
+ out.push(` – Demasiado grandes (${att.toolarge.length})`)
258
+ for (const m of att.toolarge) out.push(` • ${m.rel} (${formatBytes(m.size || 0)}, máximo ${m.maxMb} MB)`)
259
+ }
260
+ } else {
261
+ out.push('', 'IMÁGENES Y VIDEOS -- omitidos (--no-attachments)')
262
+ }
263
+
264
+ const total = plan.length + (att.uploads?.length || 0) + (att.downloads?.length || 0)
265
+ out.push('')
266
+ if (!apply) {
267
+ if (total) out.push(`Siguiente paso: revisa la lista y aplica con\n orquestra-mcp vault sync ${dirArg} --apply`)
268
+ else out.push('Nada que hacer: el vault y Orquestra están igual.')
269
+ }
270
+ return out
271
+ }
272
+
273
+ /** Resumen final de lo que SÍ se hizo. */
274
+ export function renderSummary(c) {
275
+ const parts = []
276
+ const add = (n, singular, plural) => { if (n) parts.push(`${n} ${n === 1 ? singular : plural}`) }
277
+ add(c.pull, 'nota bajada', 'notas bajadas')
278
+ add(c.push, 'nota subida', 'notas subidas')
279
+ add(c.create, 'nota creada en Orquestra', 'notas creadas en Orquestra')
280
+ add(c.conflict, 'conflicto resuelto', 'conflictos resueltos')
281
+ add(c.up, 'archivo subido', 'archivos subidos')
282
+ add(c.down, 'archivo bajado', 'archivos bajados')
283
+ const lines = []
284
+ lines.push(c.failed ? `Listo con ${c.failed} error(es) -- vuelve a correr el comando para reintentar solo lo que falló.` : parts.length ? `✓ Listo: ${parts.join(', ')}.` : '✓ Listo: no había nada que cambiar.')
285
+ if (c.conflict) lines.push(' Revisa las copias *.conflict.md junto a cada nota en conflicto; cuando termines puedes borrarlas.')
286
+ lines.push(' El estado quedó en .orquestra-sync.json (no lo borres ni lo edites). La próxima vez solo se tocará lo que cambie.')
287
+ return lines
288
+ }
289
+
172
290
  // ── Comando ──────────────────────────────────────────────────────────────────
173
291
 
174
- const LABEL = { pull: '↓ bajar', push: '↑ subir', create: '↑ crear', conflict: '⚠ conflicto', orphan: '? sin entrada en Orquestra' }
292
+ const HELP = [
293
+ 'Uso: orquestra-mcp vault sync <carpeta-del-vault> [opciones]',
294
+ '',
295
+ 'Sincroniza la base de conocimiento de tu workspace con una carpeta de Obsidian, en los dos sentidos.',
296
+ 'Córrelo desde la carpeta de un proyecto ya conectado con "orquestra-mcp setup" (de ahí salen la cuenta y el workspace).',
297
+ '',
298
+ 'Opciones:',
299
+ ' --apply escribe los cambios (la primera vez, sin esta opción solo se muestra el plan)',
300
+ ' --dry-run solo muestra el plan, aunque ya hayas sincronizado antes',
301
+ ' --no-attachments no sincroniza imágenes ni videos (solo el texto de las notas)',
302
+ ' --verbose también lista lo que ya está al día',
303
+ ' --help muestra esta ayuda',
304
+ '',
305
+ 'Qué sincroniza: notas .md (el nombre del archivo es el título, el frontmatter guarda orquestraId y tags) y las',
306
+ 'imágenes/videos (png, jpg, gif, webp, mp4, mov, webm) que las notas incrustan con ![[archivo]] o ![](ruta).',
307
+ 'Nunca borra nada. Si una nota cambia en los dos lados, gana la más reciente y la otra queda como .conflict.md.',
308
+ ]
309
+
310
+ /** Reparte `items` en `limit` tareas a la vez. */
311
+ async function mapLimit(items, limit, fn) {
312
+ const out = new Array(items.length)
313
+ let next = 0
314
+ await Promise.all(Array.from({ length: Math.min(limit, items.length) }, async () => {
315
+ while (next < items.length) { const i = next++; out[i] = await fn(items[i], i) }
316
+ }))
317
+ return out
318
+ }
175
319
 
176
320
  export async function runVaultSync(argv) {
177
321
  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
- }
322
+ if (!dirArg || argv.includes('--help')) { console.log(HELP.join('\n')); return }
184
323
  const dir = path.resolve(dirArg)
185
324
  await fs.mkdir(dir, { recursive: true })
186
325
  const config = loadConfig()
@@ -193,19 +332,82 @@ export async function runVaultSync(argv) {
193
332
  }
194
333
  // Primera vez: solo muestra el plan, salvo --apply explícito.
195
334
  const apply = argv.includes('--apply') || (!!state && !argv.includes('--dry-run'))
335
+ const withAtt = !argv.includes('--no-attachments')
336
+ const verbose = argv.includes('--verbose')
196
337
 
338
+ console.log('Leyendo Orquestra y el vault...')
197
339
  const { items: remote } = await apiGet(config, '/v1/knowledge', { projectId, full: 'true' })
198
- const { notes: local, attachments } = await readLocal(dir)
340
+ const { notes: local } = await readLocal(dir)
199
341
  const plan = planSync({ remote, local, state })
342
+ const remoteIds = new Set(remote.map(r => r.id))
200
343
 
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
344
+ // ── Adjuntos: qué subir (según el vault) y qué bajar (según Orquestra) ──
345
+ const attState = structuredClone(state?.attachments || {})
346
+ const remoteCache = new Map()
347
+ const remoteFor = async id => {
348
+ if (!remoteCache.has(id)) remoteCache.set(id, await listRemoteAttachments(config, projectId, id).catch(() => new Map()))
349
+ return remoteCache.get(id)
350
+ }
351
+ const att = { enabled: withAtt, uploads: [], downloads: [], unchanged: 0, missing: [], unsupported: [], toolarge: [] }
352
+
353
+ async function planUploads(notes) {
354
+ // Las huérfanas (orquestraId que ya no existe) no se tocan; las nuevas (sin id) se suben al crearse.
355
+ const eligible = notes.filter(n => !n.id || remoteIds.has(n.id) || n.created)
356
+ const rels = new Set()
357
+ for (const n of eligible) for (const ref of embedRefs(n.body)) rels.add(ref)
358
+ const index = buildFileIndex(await scanVaultFiles(dir))
359
+ const referenced = []
360
+ for (const n of eligible) for (const ref of embedRefs(n.body)) { const rel = resolveRef(ref, n.file, index); if (rel) referenced.push(rel) }
361
+ const files = await fileInfoFor(dir, referenced)
362
+ const items = planAttachmentUploads({ notes: eligible, files, index, attState })
363
+ // Sin estado (se perdió el .orquestra-sync.json) pero ya subido: si Orquestra tiene el mismo archivo del mismo tamaño, se adopta en vez de duplicar.
364
+ await mapLimit(items.filter(i => i.status === 'upload' && i.note.id), 4, async it => {
365
+ const r = (await remoteFor(it.note.id)).get(it.rel)
366
+ if (r && r.fileSize === it.size) { it.status = 'adopt'; it.adoptId = r.id }
367
+ })
368
+ return items
369
+ }
370
+
371
+ const classify = items => {
372
+ att.uploads = []; att.unchanged = 0; att.missing = []; att.unsupported = []; att.toolarge = []
373
+ for (const it of items) {
374
+ const row = { rel: it.rel, ref: it.ref, title: it.note.title, size: it.size, status: it.status, maxMb: it.maxMb }
375
+ if (it.status === 'upload' || it.status === 'replace') att.uploads.push(row)
376
+ else if (it.status === 'unchanged' || it.status === 'adopt') att.unchanged++
377
+ else if (it.status === 'missing') att.missing.push(row)
378
+ else if (it.status === 'unsupported') att.unsupported.push(row)
379
+ else if (it.status === 'toolarge') att.toolarge.push(row)
380
+ }
381
+ }
382
+
383
+ async function planDownloads(entryIds, titleOf) {
384
+ const rows = []
385
+ await mapLimit(entryIds, 4, async id => {
386
+ for (const a of (await remoteFor(id)).values()) {
387
+ if (!isSafeRelPath(a.fileName)) continue
388
+ if (await fs.access(path.join(dir, a.fileName)).then(() => true, () => false)) continue
389
+ rows.push({ id, rel: a.fileName, title: titleOf(id), attachment: a })
390
+ }
391
+ })
392
+ return rows
393
+ }
394
+
395
+ let uploadItems = []
396
+ let pullIds = []
397
+ if (withAtt) {
398
+ uploadItems = await planUploads(local)
399
+ classify(uploadItems)
400
+ pullIds = [...new Set(plan.filter(p => p.op === 'pull' || (p.op === 'conflict' && p.winner === 'remote')).map(p => p.remote.id))]
401
+ const titles = new Map(remote.map(r => [r.id, r.title]))
402
+ att.downloads = (await planDownloads(pullIds, id => titles.get(id) || id)).map(({ rel, title }) => ({ rel, title }))
207
403
  }
208
404
 
405
+ for (const line of renderReport({ dir, dirArg, apply, firstRun: !state, lastSyncAt: state?.syncedAt, remoteCount: remote.length, localCount: local.length, plan, att, verbose })) console.log(line)
406
+ if (verbose && state) console.log(`\n(${local.length - plan.filter(p => p.local).length} notas sin cambios)`)
407
+ if (!apply) return
408
+
409
+ // ── Aplicar ─────────────────────────────────────────────────────────────
410
+ console.log('\nAplicando...')
209
411
  const entries = { ...(state?.entries || {}) }
210
412
  const taken = new Set(local.map(l => path.basename(l.file).toLowerCase()))
211
413
  const writeRemote = async (r, file, extra = []) => {
@@ -219,17 +421,22 @@ export async function runVaultSync(argv) {
219
421
  const saved = id ? await apiPatch(config, '/v1/knowledge', { id, ...body }) : await apiPost(config, '/v1/knowledge', body)
220
422
  // Reescribe el frontmatter con el id/updatedAt nuevos, sin tocar el texto.
221
423
  await writeRemote({ ...saved, title: l.title, body: l.body, tags: saved.tags || l.tags }, l.file, l.extra)
424
+ return saved.id || id
222
425
  }
223
426
  const saveConflictCopy = async (file, text) => {
224
427
  await fs.writeFile(path.join(dir, file.replace(/\.md$/, CONFLICT_SUFFIX)), text)
225
428
  }
226
429
 
227
- let failed = 0
430
+ const done = { pull: 0, push: 0, create: 0, conflict: 0, up: 0, down: 0, failed: 0 }
431
+ const createdIds = new Set()
432
+ let n = 0
228
433
  for (const p of plan) {
434
+ n++
435
+ if (p.op === 'orphan') continue
229
436
  try {
230
437
  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
438
  else if (p.op === 'push') await push(p.local, p.remote.id)
232
- else if (p.op === 'create') await push(p.local, null)
439
+ else if (p.op === 'create') createdIds.add(await push(p.local, null))
233
440
  else if (p.op === 'conflict') {
234
441
  if (p.winner === 'local') {
235
442
  await saveConflictCopy(p.local.file, serializeNote({ id: p.remote.id, title: p.remote.title, fileTitle: '', tags: p.remote.tags || [], body: p.remote.body || '' }))
@@ -239,11 +446,55 @@ export async function runVaultSync(argv) {
239
446
  await writeRemote(p.remote, p.local.file, p.local.extra)
240
447
  }
241
448
  }
449
+ done[p.op]++
450
+ console.log(` [${n}/${plan.length}] ✓ ${DONE_LABEL[p.op]} -- ${itemName(p)}`)
242
451
  } catch (err) {
243
- failed++
244
- console.error(` ✗ ${p.local?.file || p.remote?.title}: ${err.message}`)
452
+ done.failed++
453
+ console.error(` [${n}/${plan.length}] ✗ ${itemName(p)}: ${err.message}`)
454
+ }
455
+ }
456
+
457
+ if (withAtt) {
458
+ // Con las notas ya escritas (ids nuevos, cuerpos de Orquestra) se recalcula qué subir.
459
+ const { notes: after } = await readLocal(dir)
460
+ const items = await planUploads(after.map(x => ({ ...x, created: createdIds.has(x.id) })))
461
+ const todo = items.filter(i => ['upload', 'replace', 'adopt'].includes(i.status) && i.note.id)
462
+ let k = 0
463
+ for (const it of todo) {
464
+ k++
465
+ try {
466
+ const bucket = (attState[it.note.id] ||= {})
467
+ if (it.status === 'adopt') {
468
+ bucket[it.rel] = { attachmentId: it.adoptId, hash: it.hash, size: it.size }
469
+ continue
470
+ }
471
+ bucket[it.rel] = await uploadAttachment(config, projectId, it.note.id, dir, it)
472
+ done.up++
473
+ console.log(` [${k}/${todo.length}] ✓ ↑ ${it.rel}${it.status === 'replace' ? ' (reemplazó la versión anterior)' : ''}`)
474
+ } catch (err) {
475
+ done.failed++
476
+ console.error(` [${k}/${todo.length}] ✗ ${it.rel}: ${err.message}`)
477
+ }
478
+ }
479
+ const idsToFetch = [...new Set([...pullIds, ...createdIds])]
480
+ const titles = new Map(after.map(x => [x.id, x.title]))
481
+ const downloads = await planDownloads(idsToFetch, id => titles.get(id) || id)
482
+ for (const d of downloads) {
483
+ try {
484
+ if (await downloadAttachment(config, projectId, dir, d.attachment)) {
485
+ done.down++
486
+ const info = (await fileInfoFor(dir, [d.rel])).get(d.rel)
487
+ if (info) (attState[d.id] ||= {})[d.rel] = { attachmentId: d.attachment.id, hash: info.hash, size: info.size }
488
+ console.log(` ✓ ↓ ${d.rel}`)
489
+ }
490
+ } catch (err) {
491
+ done.failed++
492
+ console.error(` ✗ ${d.rel}: ${err.message}`)
493
+ }
245
494
  }
246
495
  }
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.')
496
+
497
+ await fs.writeFile(path.join(dir, STATE_FILE), JSON.stringify({ workspaceId: config.workspaceId, syncedAt: Date.now(), entries, attachments: attState }, null, 2))
498
+ console.log('')
499
+ for (const line of renderSummary(done)) console.log(line)
249
500
  }