orquestra-mcp 1.55.0 → 1.56.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 +4 -2
- package/package.json +1 -1
- package/src/vaultAttachments.mjs +190 -0
- package/src/vaultSync.mjs +275 -24
package/bin/orquestra-mcp.mjs
CHANGED
|
@@ -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
|
|
19
|
-
carpeta de tu vault de Obsidian
|
|
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.
|
|
3
|
+
"version": "1.56.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": {
|
|
@@ -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]]` / `` 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 `` (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
|
-
//
|
|
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
|
|
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
|
|
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 .',
|
|
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
|
|
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
|
-
|
|
202
|
-
|
|
203
|
-
|
|
204
|
-
|
|
205
|
-
if (
|
|
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
|
-
|
|
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(`
|
|
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
|
-
|
|
248
|
-
|
|
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
|
}
|