@theronap/cortex-mcp 0.9.86 → 0.9.88
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/cortex-mcp.mjs +2 -3
- package/lib/editors/codex.mjs +1 -1
- package/lib/editors/cursor.mjs +1 -1
- package/lib/server.mjs +430 -4
- package/lib/setup.mjs +5 -6
- package/lib/skills.mjs +5 -179
- package/package.json +1 -1
package/bin/cortex-mcp.mjs
CHANGED
|
@@ -50,9 +50,8 @@ if (cmd === '--help' || cmd === '-h' || cmd === 'help') {
|
|
|
50
50
|
` doctor live health check — confirm your token works (no restart needed)\n` +
|
|
51
51
|
` status one-line connected/not-connected check (used by the SessionStart hook)\n` +
|
|
52
52
|
` use-brain [<brain>] where your session captures are saved — no arg shows the current setting\n` +
|
|
53
|
-
` skills
|
|
54
|
-
` skills
|
|
55
|
-
` skills push <file> [--brain <name>] publish a SKILL.md to your org (owner/manager/admin)\n` +
|
|
53
|
+
` skills install/repair the managed Agnoclast skills — bundled + org-published (also wired by setup)\n` +
|
|
54
|
+
` skills push <file> publish a SKILL.md to your org (owner/manager/admin)\n` +
|
|
56
55
|
` docs-scan detect new/changed local docs pending Agnoclast authoring (used by /cortex-author-docs)\n` +
|
|
57
56
|
` graphify-sync [path] [--brain <name-or-id>] rebuild the local code graph + log a timeline event\n` +
|
|
58
57
|
` snapshot-context save the exact startup context Agnoclast served to a local snapshot\n` +
|
package/lib/editors/codex.mjs
CHANGED
package/lib/editors/cursor.mjs
CHANGED
|
@@ -32,7 +32,7 @@ export default {
|
|
|
32
32
|
promptTimeInjection: false,
|
|
33
33
|
sessionStart: false,
|
|
34
34
|
captureHook: true, // ~/.cursor/hooks/cortex-cursor.mjs
|
|
35
|
-
skillDir:
|
|
35
|
+
skillDir: null, // Cursor rules/commands dir — revisit in Pillar 2
|
|
36
36
|
docTrigger: 'hook',
|
|
37
37
|
},
|
|
38
38
|
|
package/lib/server.mjs
CHANGED
|
@@ -223,6 +223,61 @@ export async function runServer(version) {
|
|
|
223
223
|
},
|
|
224
224
|
)
|
|
225
225
|
|
|
226
|
+
server.registerTool(
|
|
227
|
+
'maintenance_candidates',
|
|
228
|
+
{
|
|
229
|
+
title: 'Review recent project-linked maintenance evidence',
|
|
230
|
+
description: 'Start here when preparing a handoff, status, or next step for a NAMED project. Returns a bounded, RLS-scoped set of recent raw records already linked to that project, before you trust the authored page. These are candidate evidence, not a command to edit: read them, call project_status, then decide whether a material contradiction warrants a minimal accountable correction. When correcting, retain the decisive factual qualifier (for example completion date or state) in the current-status text. Never mutate merely to clear a candidate.',
|
|
231
|
+
inputSchema: {
|
|
232
|
+
project: z.string().describe('the project key or exact project name from the task, e.g. "checkout-v2" or "Checkout v2"'),
|
|
233
|
+
brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
|
|
234
|
+
since_days: z.number().optional().describe('how far back to inspect (1-90 days, default 30)'),
|
|
235
|
+
limit: z.number().optional().describe('max candidate records (1-50, default 20)'),
|
|
236
|
+
},
|
|
237
|
+
},
|
|
238
|
+
async ({ project, brain, since_days, limit }) => {
|
|
239
|
+
const qs = new URLSearchParams({ project })
|
|
240
|
+
if (brain) qs.set('brain', brain)
|
|
241
|
+
if (since_days != null) qs.set('since_days', String(since_days))
|
|
242
|
+
if (limit != null) qs.set('limit', String(limit))
|
|
243
|
+
const res = await fetchCortex(`${BASE}/api/maintenance/candidates?${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
244
|
+
if (!res.ok) {
|
|
245
|
+
const body = await res.text()
|
|
246
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
247
|
+
}
|
|
248
|
+
const { text } = await res.json()
|
|
249
|
+
return { content: [{ type: 'text', text }] }
|
|
250
|
+
},
|
|
251
|
+
)
|
|
252
|
+
|
|
253
|
+
server.registerTool(
|
|
254
|
+
'gate3_status',
|
|
255
|
+
{
|
|
256
|
+
title: 'Gate 3 currency monitor',
|
|
257
|
+
description: 'Read the aggregate-only Gate 3 currency-monitor status for this brain. It counts explicit maintenance-candidate reviews and their same-session durable corrections in the rolling window; it never exposes project names, evidence content, or session keys. "machine_evidence_ready" means enough ordinary-work evidence has accumulated to request a human closure decision, not that the monitor closes the gate itself.',
|
|
258
|
+
inputSchema: {
|
|
259
|
+
days: z.number().optional().describe('rolling window in days (1-90, default 14)'),
|
|
260
|
+
brain: z.string().optional().describe('brain name or org id when you belong to more than one brain; omit for a sole brain'),
|
|
261
|
+
},
|
|
262
|
+
},
|
|
263
|
+
async ({ days, brain }) => {
|
|
264
|
+
const qs = new URLSearchParams()
|
|
265
|
+
if (days != null) qs.set('days', String(days))
|
|
266
|
+
if (brain) qs.set('brain', brain)
|
|
267
|
+
const suffix = qs.size ? `?${qs}` : ''
|
|
268
|
+
const res = await fetchCortex(`${BASE}/api/gates/3/status${suffix}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
269
|
+
if (!res.ok) {
|
|
270
|
+
const body = await res.text()
|
|
271
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
272
|
+
}
|
|
273
|
+
const status = await res.json()
|
|
274
|
+
const text = status.machineEvidenceReady
|
|
275
|
+
? `Gate 3 machine evidence is ready: ${status.maintenanceReviewSessions} independent maintenance-review sessions and ${status.correctionSessions} correction sessions in ${status.days} days. A human should confirm these were ordinary work before closing the gate.`
|
|
276
|
+
: `Gate 3 is still collecting evidence: ${status.maintenanceReviewSessions}/${status.requiredReviewSessions} independent maintenance-review sessions and ${status.correctionSessions}/${status.requiredCorrectionSessions} correction sessions in ${status.days} days. No gate decision has been made.`
|
|
277
|
+
return { content: [{ type: 'text', text }] }
|
|
278
|
+
},
|
|
279
|
+
)
|
|
280
|
+
|
|
226
281
|
server.registerTool(
|
|
227
282
|
'session_context',
|
|
228
283
|
{
|
|
@@ -333,6 +388,161 @@ export async function runServer(version) {
|
|
|
333
388
|
},
|
|
334
389
|
)
|
|
335
390
|
|
|
391
|
+
// ── Gate 4 private intake (Slice 3) ──────────────────────────────────────────────────────────
|
|
392
|
+
server.registerTool(
|
|
393
|
+
'intake_changes',
|
|
394
|
+
{
|
|
395
|
+
title: 'Check private intake changes',
|
|
396
|
+
description:
|
|
397
|
+
'Session heartbeat for Gate 4 private intake. Returns an operational delta since your cursor (no ciphertext) plus cleanupDueCount. Call at session start, before each user turn, after long actions, and at least every two minutes while actively working. If cleanupDueCount > 0, call intake_claim with claimKind=cleanup before ordinary work.',
|
|
398
|
+
inputSchema: {
|
|
399
|
+
afterSeq: z.number().optional().describe('cursor from the previous call (default 0)'),
|
|
400
|
+
limit: z.number().optional().describe('max change rows (default 100)'),
|
|
401
|
+
},
|
|
402
|
+
},
|
|
403
|
+
async ({ afterSeq, limit }) => {
|
|
404
|
+
const params = new URLSearchParams()
|
|
405
|
+
if (typeof afterSeq === 'number') params.set('afterSeq', String(afterSeq))
|
|
406
|
+
if (typeof limit === 'number') params.set('limit', String(limit))
|
|
407
|
+
const qs = params.toString() ? `?${params}` : ''
|
|
408
|
+
const res = await fetchCortex(`${BASE}/api/intake/changes${qs}`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
409
|
+
if (!res.ok) {
|
|
410
|
+
const body = await res.text()
|
|
411
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
412
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
413
|
+
}
|
|
414
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
415
|
+
},
|
|
416
|
+
)
|
|
417
|
+
|
|
418
|
+
server.registerTool(
|
|
419
|
+
'intake_claim',
|
|
420
|
+
{
|
|
421
|
+
title: 'Claim private intake items',
|
|
422
|
+
description:
|
|
423
|
+
'Claim a lease on private intake items (relevance or cleanup). Returns decrypted payloads for the lease holder only. If cleanup is due, relevance claims return 409 — process cleanup first. Requires x-cortex-session-key (set automatically by this MCP server).',
|
|
424
|
+
inputSchema: {
|
|
425
|
+
claimKind: z.enum(['relevance', 'cleanup']).optional().describe('default relevance'),
|
|
426
|
+
limit: z.number().optional().describe('max items (default 10)'),
|
|
427
|
+
},
|
|
428
|
+
},
|
|
429
|
+
async ({ claimKind, limit }) => {
|
|
430
|
+
const res = await fetchCortex(`${BASE}/api/intake/claim`, {
|
|
431
|
+
method: 'POST',
|
|
432
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
433
|
+
body: JSON.stringify({ claimKind: claimKind ?? 'relevance', limit, includePayload: true }),
|
|
434
|
+
})
|
|
435
|
+
if (!res.ok) {
|
|
436
|
+
const body = await res.text()
|
|
437
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
438
|
+
if (res.status === 409) return toolError(body)
|
|
439
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
440
|
+
}
|
|
441
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
442
|
+
},
|
|
443
|
+
)
|
|
444
|
+
|
|
445
|
+
server.registerTool(
|
|
446
|
+
'intake_materialize',
|
|
447
|
+
{
|
|
448
|
+
title: 'Materialize a private intake item',
|
|
449
|
+
description:
|
|
450
|
+
'Atomically publish a claimed intake item into one brain. Deterministic identifier homes in that brain are always attached; documentIds may add further pages. Sets record confidentiality to the strictest attached page tier. Never writes private intake into search/history before this call.',
|
|
451
|
+
inputSchema: {
|
|
452
|
+
intakeItemId: z.string().describe('intake item uuid'),
|
|
453
|
+
orgId: z.string().describe('destination brain org uuid'),
|
|
454
|
+
documentIds: z.array(z.string()).optional().describe('additional brain_documents ids in orgId (deterministic homes are merged automatically)'),
|
|
455
|
+
title: z.string().optional(),
|
|
456
|
+
summary: z.string().optional(),
|
|
457
|
+
source: z.string().optional(),
|
|
458
|
+
recordType: z.string().optional(),
|
|
459
|
+
dedupeKey: z.string().optional(),
|
|
460
|
+
origin: z.enum(['deterministic', 'llm', 'user']).optional(),
|
|
461
|
+
},
|
|
462
|
+
},
|
|
463
|
+
async (args) => {
|
|
464
|
+
const res = await fetchCortex(`${BASE}/api/intake/materialize`, {
|
|
465
|
+
method: 'POST',
|
|
466
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
467
|
+
body: JSON.stringify({
|
|
468
|
+
intakeItemId: args.intakeItemId,
|
|
469
|
+
orgId: args.orgId,
|
|
470
|
+
documentIds: args.documentIds ?? [],
|
|
471
|
+
record: {
|
|
472
|
+
title: args.title,
|
|
473
|
+
summary: args.summary,
|
|
474
|
+
source: args.source,
|
|
475
|
+
record_type: args.recordType,
|
|
476
|
+
dedupe_key: args.dedupeKey,
|
|
477
|
+
},
|
|
478
|
+
attachmentMeta: (args.documentIds ?? []).map(() => ({ origin: args.origin ?? 'llm' })),
|
|
479
|
+
}),
|
|
480
|
+
})
|
|
481
|
+
if (!res.ok) {
|
|
482
|
+
const body = await res.text()
|
|
483
|
+
return toolError(body)
|
|
484
|
+
}
|
|
485
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
486
|
+
},
|
|
487
|
+
)
|
|
488
|
+
|
|
489
|
+
server.registerTool(
|
|
490
|
+
'intake_discard',
|
|
491
|
+
{
|
|
492
|
+
title: 'Discard a private intake item',
|
|
493
|
+
description:
|
|
494
|
+
'The OTHER terminal outcome for a claimed intake unit: this is nothing, drop it. Use it for content that should never become a record — unsubscribe receipts, empty greetings, marketing blasts, a stranger\'s photo — instead of materializing junk into a brain because materialize was the only verb available. IRREVERSIBLE: the ciphertext and nonces are destroyed in the same transaction, and a content-free tombstone stops the connector re-delivering the unit. You must already hold the claim (discarding something you never read is refused), `reason` is required and is stored, and it is one item per call — a loop discarding a whole pile on one decision is a bulk job, not judgment.',
|
|
495
|
+
inputSchema: {
|
|
496
|
+
intakeItemId: z.string().describe('intake item uuid from intake_claim'),
|
|
497
|
+
reason: z.string().describe('why this is nothing — recorded on the claim, and the only surviving trace of the decision'),
|
|
498
|
+
},
|
|
499
|
+
},
|
|
500
|
+
async ({ intakeItemId, reason }) => {
|
|
501
|
+
let res
|
|
502
|
+
try {
|
|
503
|
+
res = await fetchCortex(`${BASE}/api/intake/discard`, {
|
|
504
|
+
method: 'POST',
|
|
505
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
506
|
+
body: JSON.stringify({ intakeItemId, reason }),
|
|
507
|
+
})
|
|
508
|
+
} catch (e) {
|
|
509
|
+
return toolError(`Could not discard: ${e.message}`)
|
|
510
|
+
}
|
|
511
|
+
const out = await res.json().catch(() => null)
|
|
512
|
+
if (!res.ok) {
|
|
513
|
+
if (out?.error === 'not_claimed') {
|
|
514
|
+
return toolError('You do not hold a claim on that item — intake_claim it first, so the discard follows from having read it.')
|
|
515
|
+
}
|
|
516
|
+
if (out?.error === 'already_materialized') {
|
|
517
|
+
return toolError('That unit already became a record. Discarding it now would orphan the record from its source — detach or retier the record instead.')
|
|
518
|
+
}
|
|
519
|
+
return toolError(`Could not discard: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
520
|
+
}
|
|
521
|
+
if (out?.alreadyDiscarded) {
|
|
522
|
+
return { content: [{ type: 'text', text: 'Already discarded — nothing to do.' }] }
|
|
523
|
+
}
|
|
524
|
+
return { content: [{ type: 'text', text: 'Discarded. Content destroyed, tombstone written so the source cannot re-deliver it.' }] }
|
|
525
|
+
},
|
|
526
|
+
)
|
|
527
|
+
|
|
528
|
+
server.registerTool(
|
|
529
|
+
'intake_cleanup_status',
|
|
530
|
+
{
|
|
531
|
+
title: 'Private intake cleanup status',
|
|
532
|
+
description: 'Counts of pending/claimed/awaiting/cleanup-due intake items plus open clarifying questions for the owner.',
|
|
533
|
+
inputSchema: {},
|
|
534
|
+
},
|
|
535
|
+
async () => {
|
|
536
|
+
const res = await fetchCortex(`${BASE}/api/intake/cleanup-status`, { headers: { Authorization: `Bearer ${TOKEN}` } })
|
|
537
|
+
if (!res.ok) {
|
|
538
|
+
const body = await res.text()
|
|
539
|
+
if (res.status === 403) return toolError('Private intake is not enabled for this account.')
|
|
540
|
+
throw new Error(classify(res.status, res.headers.get('content-type'), body, res.headers.get('x-vercel-id')).message)
|
|
541
|
+
}
|
|
542
|
+
return { content: [{ type: 'text', text: JSON.stringify(await res.json(), null, 2) }] }
|
|
543
|
+
},
|
|
544
|
+
)
|
|
545
|
+
|
|
336
546
|
server.registerTool(
|
|
337
547
|
'search_org',
|
|
338
548
|
{
|
|
@@ -1451,6 +1661,222 @@ export async function runServer(version) {
|
|
|
1451
1661
|
},
|
|
1452
1662
|
)
|
|
1453
1663
|
|
|
1664
|
+
server.registerTool(
|
|
1665
|
+
'set_routing_identifier',
|
|
1666
|
+
{
|
|
1667
|
+
title: 'Claim a routing identifier on a page',
|
|
1668
|
+
description: 'Declare that a page is a Gate 4 attach HOME for an identifier (repo:owner/name or file:owner/name:path). Body [[repo:…]] stamps are navigation only and do NOT drive attach — use this instead. Prefer the narrowest id: mother pages own repo:; feature pages own file: paths. Do not copy every body mention into a routing claim.',
|
|
1669
|
+
inputSchema: {
|
|
1670
|
+
kind: z.enum(['project', 'person', 'org', 'user']).describe('the page kind'),
|
|
1671
|
+
name: z.string().optional().describe('page title (or pass ref)'),
|
|
1672
|
+
ref: z.string().optional().describe('node ref from read_page — prefer over name when available'),
|
|
1673
|
+
brain: z.string().optional().describe('brain label when the title is ambiguous across brains'),
|
|
1674
|
+
identifier: z.string().describe('canonical identifier, e.g. repo:theronap/cortex or file:theronap/cortex:web/lib/engine/github_intake.ts'),
|
|
1675
|
+
},
|
|
1676
|
+
},
|
|
1677
|
+
async ({ kind, name, ref, brain, identifier }) => {
|
|
1678
|
+
if (!name && !ref) return toolError('Pass name or ref')
|
|
1679
|
+
let res
|
|
1680
|
+
try {
|
|
1681
|
+
res = await fetchCortex(`${BASE}/api/brain/routing-identifiers`, {
|
|
1682
|
+
method: 'POST',
|
|
1683
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1684
|
+
body: JSON.stringify({ kind, name, ref, brain, identifier }),
|
|
1685
|
+
})
|
|
1686
|
+
} catch (e) {
|
|
1687
|
+
return toolError(`Could not set routing identifier: ${e.message}`)
|
|
1688
|
+
}
|
|
1689
|
+
const out = await res.json().catch(() => null)
|
|
1690
|
+
if (!res.ok) return toolError(`Could not set routing identifier: ${out?.error ?? res.status}`)
|
|
1691
|
+
const set = out?.set?.join(', ') ?? identifier
|
|
1692
|
+
return { content: [{ type: 'text', text: `Routing identifier set on document ${out?.documentId ?? '?'}: ${set}. Future matching events will attach here (body mentions alone will not).` }] }
|
|
1693
|
+
},
|
|
1694
|
+
)
|
|
1695
|
+
|
|
1696
|
+
// ── Gate 4 record triage, over timeline_claims ────────────────────────────────────────────
|
|
1697
|
+
// A connector event materializes in seconds and has no idea what the work WAS. The session that
|
|
1698
|
+
// did the work knows exactly, and arrives later. These three tools are that handoff: look at what
|
|
1699
|
+
// landed, claim what is yours, route it when you know where it goes.
|
|
1700
|
+
//
|
|
1701
|
+
// The ledger is `timeline_claims` (0105) — one claim discipline over one stream. Unclaimed means
|
|
1702
|
+
// NO claim row: absence IS the backlog, and nothing is written at ingest.
|
|
1703
|
+
|
|
1704
|
+
server.registerTool(
|
|
1705
|
+
'pending_records',
|
|
1706
|
+
{
|
|
1707
|
+
title: 'Records waiting for a home',
|
|
1708
|
+
description: 'List recent connector records (GitHub pushes, PRs, email) that NOBODY HAS ATTENDED TO yet — no claim row in the Gate 4 ledger. Check this when your session starts if the headline count sounds related to what you are about to work on; records from your own recent commits are usually in here, and you are the only one who can recognize them as yours. Returns titles and current homes only, never payloads. Use view "sweep" on one record to get graded page-name candidates without reading any pages.',
|
|
1709
|
+
inputSchema: {
|
|
1710
|
+
view: z.enum(['digest', 'sweep']).optional().describe('digest = what is waiting (default); sweep = cheap graded candidates for one record'),
|
|
1711
|
+
recordId: z.string().optional().describe('required for view "sweep"'),
|
|
1712
|
+
hours: z.number().int().positive().optional().describe('lookback window, default 3'),
|
|
1713
|
+
stale: z.boolean().optional().describe('include old unclaimed records — the cleanup pile, not the live one'),
|
|
1714
|
+
limit: z.number().int().positive().optional(),
|
|
1715
|
+
},
|
|
1716
|
+
},
|
|
1717
|
+
async ({ view, recordId, hours, stale, limit }) => {
|
|
1718
|
+
const params = new URLSearchParams()
|
|
1719
|
+
params.set('view', view === 'sweep' ? 'sweep' : 'digest')
|
|
1720
|
+
if (recordId) params.set('recordId', recordId)
|
|
1721
|
+
if (hours) params.set('hours', String(hours))
|
|
1722
|
+
if (stale) params.set('stale', '1')
|
|
1723
|
+
if (limit) params.set('limit', String(limit))
|
|
1724
|
+
|
|
1725
|
+
let res
|
|
1726
|
+
try {
|
|
1727
|
+
res = await fetchCortex(`${BASE}/api/brain/triage?${params}`, {
|
|
1728
|
+
headers: { Authorization: `Bearer ${TOKEN}` },
|
|
1729
|
+
})
|
|
1730
|
+
} catch (e) {
|
|
1731
|
+
return toolError(`Could not read pending records: ${e.message}`)
|
|
1732
|
+
}
|
|
1733
|
+
const out = await res.json().catch(() => null)
|
|
1734
|
+
if (!res.ok) return toolError(`Could not read pending records: ${out?.error ?? res.status}`)
|
|
1735
|
+
|
|
1736
|
+
if (view === 'sweep') {
|
|
1737
|
+
const cands = out?.candidates ?? []
|
|
1738
|
+
if (cands.length === 0) {
|
|
1739
|
+
return { content: [{ type: 'text', text: `No page-name candidates for "${out?.title ?? recordId}". Park it — a stale record with no match is not worth reading pages over.` }] }
|
|
1740
|
+
}
|
|
1741
|
+
const lines = cands.map((c) => ` ${c.strength === 'strong' ? '●' : '○'} ${c.title} (${c.strength}) — ${c.documentId}`)
|
|
1742
|
+
const rec = out?.recommendation
|
|
1743
|
+
return {
|
|
1744
|
+
content: [{
|
|
1745
|
+
type: 'text',
|
|
1746
|
+
text: `Candidates for "${out?.title}":\n${lines.join('\n')}\n\nRecommended: ${rec?.action} — ${rec?.why}\n● strong = titles contain each other, safe to route. ○ weak = one generic word matched; read those pages ONLY if this record is worth the tokens, otherwise park.`,
|
|
1747
|
+
}],
|
|
1748
|
+
}
|
|
1749
|
+
}
|
|
1750
|
+
|
|
1751
|
+
const records = out?.records ?? []
|
|
1752
|
+
if (records.length === 0) {
|
|
1753
|
+
return { content: [{ type: 'text', text: 'Nothing waiting for a home.' }] }
|
|
1754
|
+
}
|
|
1755
|
+
const lines = records.map((r) => {
|
|
1756
|
+
const homes = r.currentHomes?.length ? r.currentHomes.join(', ') : 'nothing'
|
|
1757
|
+
const held = r.claimedBySession ? ` [claimed: ${String(r.claimedBySession).slice(0, 12)}…]` : ''
|
|
1758
|
+
return ` • ${r.title}\n ${r.source} · ${r.ageHours}h ago · on: ${homes}${held}\n ${r.recordId}`
|
|
1759
|
+
})
|
|
1760
|
+
return {
|
|
1761
|
+
content: [{
|
|
1762
|
+
type: 'text',
|
|
1763
|
+
text: `${records.length} record(s) waiting for a home:\n\n${lines.join('\n\n')}\n\nRecognize any as your own work? claim_record it now, then route_record once you know where it belongs.`,
|
|
1764
|
+
}],
|
|
1765
|
+
}
|
|
1766
|
+
},
|
|
1767
|
+
)
|
|
1768
|
+
|
|
1769
|
+
server.registerTool(
|
|
1770
|
+
'claim_record',
|
|
1771
|
+
{
|
|
1772
|
+
title: 'Claim a pending record as your work',
|
|
1773
|
+
description: 'Say "this record is mine, I will route it once I know where it goes." Use it as soon as you recognize your own work in pending_records, even before you know the destination page — the claim takes it out of the backlog so nothing else guesses at something you have real context on. Claims are leased and expire, so a dead session never holds a record hostage, and a record held by another LIVE session cannot be taken. Pass release=true to give one back when it turns out not to be yours — that deletes the claim, so the record looks untouched again rather than attended-to.',
|
|
1774
|
+
inputSchema: {
|
|
1775
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
1776
|
+
note: z.string().optional().describe('what you think this is — kept for audit'),
|
|
1777
|
+
leaseMinutes: z.number().int().positive().optional().describe('how long you need it, default 90'),
|
|
1778
|
+
release: z.boolean().optional().describe('give the claim back instead of taking it'),
|
|
1779
|
+
},
|
|
1780
|
+
},
|
|
1781
|
+
async ({ recordId, note, leaseMinutes, release }) => {
|
|
1782
|
+
let res
|
|
1783
|
+
try {
|
|
1784
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
1785
|
+
method: 'POST',
|
|
1786
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1787
|
+
body: JSON.stringify({ action: release ? 'release' : 'claim', recordId, note, leaseMinutes }),
|
|
1788
|
+
})
|
|
1789
|
+
} catch (e) {
|
|
1790
|
+
return toolError(`Could not claim record: ${e.message}`)
|
|
1791
|
+
}
|
|
1792
|
+
const out = await res.json().catch(() => null)
|
|
1793
|
+
if (!res.ok) {
|
|
1794
|
+
if (out?.error === 'already_claimed') {
|
|
1795
|
+
return toolError(`Another live session is already holding that record (${String(out.heldBy).slice(0, 16)}…). Leave it to them.`)
|
|
1796
|
+
}
|
|
1797
|
+
return toolError(`Could not claim record: ${out?.error ?? res.status}`)
|
|
1798
|
+
}
|
|
1799
|
+
if (release) return { content: [{ type: 'text', text: 'Released — it is back in the pending pool.' }] }
|
|
1800
|
+
return { content: [{ type: 'text', text: `Claimed until ${out?.expiresAt ?? 'the lease expires'}. Call route_record when you know where it belongs.` }] }
|
|
1801
|
+
},
|
|
1802
|
+
)
|
|
1803
|
+
|
|
1804
|
+
server.registerTool(
|
|
1805
|
+
'route_record',
|
|
1806
|
+
{
|
|
1807
|
+
title: 'Route a record to the pages it belongs on',
|
|
1808
|
+
description: 'Attach a pending record to the pages you judge correct — the point of the whole triage path. Use this when you have real context on what the work was; that judgment is better than any rule the webhook could run. Attachments are additive: existing deterministic homes (routing identifiers, your profile) stay. Pass park=true instead when you have looked and there is genuinely no good home — parking beats attaching to a page that merely shares a word.',
|
|
1809
|
+
inputSchema: {
|
|
1810
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
1811
|
+
documentIds: z.array(z.string()).optional().describe('page document ids to attach (from pending_records sweep, or read_page)'),
|
|
1812
|
+
reason: z.string().describe('why these pages — recorded with the attachment'),
|
|
1813
|
+
park: z.boolean().optional().describe('no good home exists; leave it alone rather than guessing'),
|
|
1814
|
+
tier: z.number().int().optional().describe('2 when this came from the cheap sweep rather than your own context'),
|
|
1815
|
+
},
|
|
1816
|
+
},
|
|
1817
|
+
async ({ recordId, documentIds, reason, park, tier }) => {
|
|
1818
|
+
let res
|
|
1819
|
+
try {
|
|
1820
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
1821
|
+
method: 'POST',
|
|
1822
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1823
|
+
body: JSON.stringify({ action: park ? 'park' : 'route', recordId, documentIds, reason, tier }),
|
|
1824
|
+
})
|
|
1825
|
+
} catch (e) {
|
|
1826
|
+
return toolError(`Could not route record: ${e.message}`)
|
|
1827
|
+
}
|
|
1828
|
+
const out = await res.json().catch(() => null)
|
|
1829
|
+
if (!res.ok) return toolError(`Could not route record: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
1830
|
+
if (park) return { content: [{ type: 'text', text: 'Parked. It stays visible and unrouted rather than badly attached.' }] }
|
|
1831
|
+
return { content: [{ type: 'text', text: `Routed — attached to ${out?.attached?.length ?? 0} page(s). Recorded as a session judgment, not a rule match.` }] }
|
|
1832
|
+
},
|
|
1833
|
+
)
|
|
1834
|
+
|
|
1835
|
+
server.registerTool(
|
|
1836
|
+
'unroute_record',
|
|
1837
|
+
{
|
|
1838
|
+
title: 'Remove pages a record should not be on',
|
|
1839
|
+
description:
|
|
1840
|
+
'Detach pages a record does not belong on — the inverse of route_record, and the only way a wrong placement can be undone. route_record is purely ADDITIVE, so attaching more pages can never fix a bad one. Use this when you can see a record sitting on a page it has no real relationship to — the classic case is a fuzzy title match, e.g. a commit attached to a page merely because both contain a common word. Two things it will refuse rather than surprise you: it will not remove every attachment (a record with no home is invisible, which is worse than a wrong home — route or park it instead), and detaching the page that GOVERNS the tier can tighten the record but never republish it, since a widening is pinned and proposed for a human to confirm.',
|
|
1841
|
+
inputSchema: {
|
|
1842
|
+
recordId: z.string().describe('record id from pending_records'),
|
|
1843
|
+
documentIds: z.array(z.string()).describe('page document ids to REMOVE from this record'),
|
|
1844
|
+
reason: z.string().describe('why these placements are wrong — recorded with the removal'),
|
|
1845
|
+
},
|
|
1846
|
+
},
|
|
1847
|
+
async ({ recordId, documentIds, reason }) => {
|
|
1848
|
+
let res
|
|
1849
|
+
try {
|
|
1850
|
+
res = await fetchCortex(`${BASE}/api/brain/triage`, {
|
|
1851
|
+
method: 'POST',
|
|
1852
|
+
headers: { Authorization: `Bearer ${TOKEN}`, 'Content-Type': 'application/json' },
|
|
1853
|
+
body: JSON.stringify({ action: 'detach', recordId, documentIds, reason }),
|
|
1854
|
+
})
|
|
1855
|
+
} catch (e) {
|
|
1856
|
+
return toolError(`Could not detach: ${e.message}`)
|
|
1857
|
+
}
|
|
1858
|
+
const out = await res.json().catch(() => null)
|
|
1859
|
+
if (!res.ok) {
|
|
1860
|
+
// These two are guard rails, not faults — say what to do instead of just naming the code.
|
|
1861
|
+
if (out?.error === 'would_strand') {
|
|
1862
|
+
return toolError(`Refused: ${out.detail ?? 'that would leave the record with no pages at all.'}`)
|
|
1863
|
+
}
|
|
1864
|
+
if (out?.error === 'not_attached') {
|
|
1865
|
+
return toolError(`Nothing removed: ${out.detail ?? 'those pages are not attached to this record.'}`)
|
|
1866
|
+
}
|
|
1867
|
+
return toolError(`Could not detach: ${out?.error ?? res.status}${out?.detail ? ` — ${out.detail}` : ''}`)
|
|
1868
|
+
}
|
|
1869
|
+
const n = out?.detached?.length ?? 0
|
|
1870
|
+
const left = out?.remaining ?? 0
|
|
1871
|
+
return {
|
|
1872
|
+
content: [{
|
|
1873
|
+
type: 'text',
|
|
1874
|
+
text: `Detached ${n} page(s); ${left} attachment(s) remain. Record tier: ${out?.privacy ?? 'unchanged'}. Recorded as a session judgment.`,
|
|
1875
|
+
}],
|
|
1876
|
+
}
|
|
1877
|
+
},
|
|
1878
|
+
)
|
|
1879
|
+
|
|
1454
1880
|
server.registerTool(
|
|
1455
1881
|
'snooze_red_link',
|
|
1456
1882
|
{
|
|
@@ -1699,7 +2125,7 @@ export async function runServer(version) {
|
|
|
1699
2125
|
'request_person_page_merge',
|
|
1700
2126
|
{
|
|
1701
2127
|
title: 'Offer one duplicate person page for merge',
|
|
1702
|
-
description: 'Offer YOUR accessible person
|
|
2128
|
+
description: 'Offer YOUR accessible person NODE for full merge into another current accessible person node in the SAME named brain. Use only when the two nodes are unquestionably the same real person. Read both pages first and pass their refs and versions. This never crosses brains and does not use fuzzy matching.',
|
|
1703
2129
|
inputSchema: {
|
|
1704
2130
|
brain: z.string().describe('the exact brain name or brain id; pass an id if names collide'),
|
|
1705
2131
|
source_ref: z.string().describe('ref of YOUR duplicate source person page'),
|
|
@@ -1727,7 +2153,7 @@ export async function runServer(version) {
|
|
|
1727
2153
|
'person_page_merge_requests',
|
|
1728
2154
|
{
|
|
1729
2155
|
title: 'Review duplicate-person page merge requests',
|
|
1730
|
-
description: 'List pending same-brain person-
|
|
2156
|
+
description: 'List pending same-brain person-node merges where you own the canonical page. Read both pages before applying. Applying preserves source page prose/history, moves its raw person attachments (mentions, actor evidence, identifiers, aliases and graph edges) onto the canonical node, and leaves a hidden source alias plus redirect. It refuses conflicting private variants rather than widening them.',
|
|
1731
2157
|
inputSchema: {},
|
|
1732
2158
|
},
|
|
1733
2159
|
async () => {
|
|
@@ -1746,7 +2172,7 @@ export async function runServer(version) {
|
|
|
1746
2172
|
'apply_person_page_merge',
|
|
1747
2173
|
{
|
|
1748
2174
|
title: 'Apply a reviewed duplicate-person page merge',
|
|
1749
|
-
description: 'Apply a pending same-brain person-
|
|
2175
|
+
description: 'Apply a pending full same-brain person-node merge you own. Re-read the canonical page immediately before applying and pass its version. This is atomic and preservation-first: source prose/history is retained, raw evidence and connections are re-pointed to the canonical node, identifiers are unioned, and the source becomes a hidden alias with a durable redirect. A privacy or identity-metadata conflict refuses the whole merge.',
|
|
1750
2176
|
inputSchema: {
|
|
1751
2177
|
request_id: z.string().describe('id from person_page_merge_requests'),
|
|
1752
2178
|
target_version: z.string().describe('current canonical-page version from a fresh read'),
|
|
@@ -1763,7 +2189,7 @@ export async function runServer(version) {
|
|
|
1763
2189
|
} catch (e) { return toolError(`Could not apply page merge: ${e.message}`) }
|
|
1764
2190
|
const out = await res.json().catch(() => null)
|
|
1765
2191
|
if (!res.ok) return toolError(`Could not apply page merge: ${out?.error ?? res.status}`)
|
|
1766
|
-
return { content: [{ type: 'text', text: `
|
|
2192
|
+
return { content: [{ type: 'text', text: `Fully merged "${out.sourceTitle}" into "${out.targetTitle}". Preserved ${out.preservedSections} source page block(s) and moved ${out.movedAttachments} raw attachment(s); source is now a hidden alias with a durable redirect.` }] }
|
|
1767
2193
|
},
|
|
1768
2194
|
)
|
|
1769
2195
|
|
package/lib/setup.mjs
CHANGED
|
@@ -141,16 +141,15 @@ export async function runSetup(argv, version) {
|
|
|
141
141
|
process.exit(1)
|
|
142
142
|
}
|
|
143
143
|
|
|
144
|
-
// ── 3. Managed skills — installed flat into every
|
|
145
|
-
// Bundled first (sync, network-free), then org
|
|
146
|
-
//
|
|
147
|
-
// only if the caller exported it;
|
|
144
|
+
// ── 3. Managed skills — installed flat into every agent CLI present (Claude + Codex) ──
|
|
145
|
+
// Bundled first (sync, network-free), then the org-published set (fail-soft pull), so a fresh
|
|
146
|
+
// seat has its org's skills at first session, not second. CORTEX_TOKEN is in env for the pull
|
|
147
|
+
// only if the caller exported it; syncOrgSkills falls back to the token this setup just wired.
|
|
148
148
|
try {
|
|
149
149
|
installSkills({ quiet: false })
|
|
150
|
-
const { syncOrgSkills
|
|
150
|
+
const { syncOrgSkills } = await import('./skills.mjs')
|
|
151
151
|
process.env.CORTEX_TOKEN = process.env.CORTEX_TOKEN || token
|
|
152
152
|
await syncOrgSkills({ quiet: false })
|
|
153
|
-
await syncPersonalSkills({ quiet: false })
|
|
154
153
|
} catch (e) {
|
|
155
154
|
// Non-fatal: a skills hiccup must never block the core connection. Repair runs each session.
|
|
156
155
|
log(` ⚠ skills install skipped: ${e.message} (will retry on next session)`)
|
package/lib/skills.mjs
CHANGED
|
@@ -5,7 +5,7 @@ import { fileURLToPath } from 'url'
|
|
|
5
5
|
|
|
6
6
|
// Managed Agnoclast skills.
|
|
7
7
|
//
|
|
8
|
-
//
|
|
8
|
+
// Two sources, one install pipeline:
|
|
9
9
|
// 1. BUNDLED — shipped inside this package (cortex-log, cortex-context, cortex-author-docs).
|
|
10
10
|
// Inalterable: canonical content always wins; user edits are backed up + restored.
|
|
11
11
|
// 2. ORG-PUBLISHED — rows in the org's `org_skills` table (documentation-ingestion-spec.md
|
|
@@ -13,9 +13,6 @@ import { fileURLToPath } from 'url'
|
|
|
13
13
|
// SessionStart `skills --repair` hook pulls + installs it here. Bundled names WIN collisions,
|
|
14
14
|
// so an org can never shadow a core skill. The pull is fail-soft (offline → last-good cache
|
|
15
15
|
// at ~/.cortex/org-skills-cache.json → skip), because SessionStart must never break.
|
|
16
|
-
// 3. PERSONAL — rows in `personal_skills`, keyed by auth_id. These are private to one person and
|
|
17
|
-
// are projected into every compatible editor on their machines. Personal wins an org collision
|
|
18
|
-
// on that person's machine; bundled core still wins every collision.
|
|
19
16
|
//
|
|
20
17
|
// Installed into EVERY agent CLI present on the machine, at the flat layout each one discovers:
|
|
21
18
|
// ~/.claude/skills/<name>/SKILL.md (Claude Code)
|
|
@@ -33,16 +30,13 @@ import { fileURLToPath } from 'url'
|
|
|
33
30
|
const HERE = dirname(fileURLToPath(import.meta.url))
|
|
34
31
|
const BUNDLED = join(HERE, '..', 'skills') // packages/cortex-mcp/skills/<name>/SKILL.md
|
|
35
32
|
const ORG_CACHE = join(homedir(), '.cortex', 'org-skills-cache.json')
|
|
36
|
-
const PERSONAL_CACHE = join(homedir(), '.cortex', 'personal-skills-cache.json')
|
|
37
33
|
const NAME_RE = /^[a-z0-9][a-z0-9-]{0,63}$/ // mirrors the org_skills check; also blocks path tricks
|
|
38
34
|
|
|
39
|
-
//
|
|
40
|
-
//
|
|
41
|
-
// deliberately do not fabricate one.
|
|
35
|
+
// Agent CLIs we install skills into. Claude is primary; Codex is included whenever it's present.
|
|
36
|
+
// Both use the same flat <cli>/skills/<name>/SKILL.md discovery layout.
|
|
42
37
|
const CLIS = [
|
|
43
38
|
{ id: 'Claude Code', dir: join(homedir(), '.claude') },
|
|
44
39
|
{ id: 'Codex', dir: join(homedir(), '.codex') },
|
|
45
|
-
{ id: 'Cursor', dir: join(homedir(), '.cursor') },
|
|
46
40
|
]
|
|
47
41
|
|
|
48
42
|
// djb2 — tiny, dependency-free content fingerprint for the manifest (drift detection, not security).
|
|
@@ -202,25 +196,6 @@ export function planOrgInstall(served, bundledNames) {
|
|
|
202
196
|
return { install, skipped }
|
|
203
197
|
}
|
|
204
198
|
|
|
205
|
-
// Personal skills share the same validation and bundled-collision rule as org skills, but never use
|
|
206
|
-
// the cross-brain collision rule: their auth_id uniqueness means only one personal body can exist per
|
|
207
|
-
// name. Keeping this pure makes the precedence contract testable without touching a real home dir.
|
|
208
|
-
export function planPersonalInstall(served, bundledNames) {
|
|
209
|
-
const bundled = new Set(bundledNames)
|
|
210
|
-
const install = []
|
|
211
|
-
const skipped = []
|
|
212
|
-
const seen = new Set()
|
|
213
|
-
for (const s of served ?? []) {
|
|
214
|
-
const name = (s?.name ?? '').trim()
|
|
215
|
-
if (!NAME_RE.test(name) || typeof s?.body_md !== 'string' || !s.body_md.trim()) { skipped.push({ name: name || '(unnamed)', why: 'invalid' }); continue }
|
|
216
|
-
if (bundled.has(name)) { skipped.push({ name, why: 'collides with a bundled core skill' }); continue }
|
|
217
|
-
if (seen.has(name)) { skipped.push({ name, why: 'duplicate personal skill' }); continue }
|
|
218
|
-
seen.add(name)
|
|
219
|
-
install.push({ name, source: s.body_md })
|
|
220
|
-
}
|
|
221
|
-
return { install, skipped }
|
|
222
|
-
}
|
|
223
|
-
|
|
224
199
|
/**
|
|
225
200
|
* Pull the org's published skills and install them beside the bundled ones. FAIL-SOFT by design:
|
|
226
201
|
* no token → skip; fetch failure → last-good cache; nothing → skip. SessionStart must never break.
|
|
@@ -261,10 +236,7 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
261
236
|
|
|
262
237
|
const { install, skipped } = planOrgInstall(served, bundledSkills().map((s) => s.name))
|
|
263
238
|
summary.skipped = skipped
|
|
264
|
-
|
|
265
|
-
// older/cache-shaped-alike file is not proof of ownership; treating it as one deleted source
|
|
266
|
-
// skills that had not yet been accepted by the server. First sync after an upgrade is additive.
|
|
267
|
-
const prevNames = cache?.version === 1 && Array.isArray(cache?.installed) ? cache.installed : []
|
|
239
|
+
const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
|
|
268
240
|
const currentNames = install.map((s) => s.name)
|
|
269
241
|
const removeNames = prevNames.filter((n) => !currentNames.includes(n))
|
|
270
242
|
|
|
@@ -293,76 +265,6 @@ export async function syncOrgSkills(opts = {}) {
|
|
|
293
265
|
return summary
|
|
294
266
|
}
|
|
295
267
|
|
|
296
|
-
/**
|
|
297
|
-
* Pull the caller's personal skill library and project it into every compatible local editor.
|
|
298
|
-
* It is deliberately fail-soft for the same reason as org sync: a network outage must not break a
|
|
299
|
-
* session start. A personal skill wins an unpinned org skill by being installed after org sync.
|
|
300
|
-
*/
|
|
301
|
-
export async function syncPersonalSkills(opts = {}) {
|
|
302
|
-
const quiet = !!opts.quiet
|
|
303
|
-
const log = (m) => { if (!quiet) process.stdout.write(m + '\n') }
|
|
304
|
-
const summary = { installed: [], repaired: [], removed: [], skipped: [], source: 'none' }
|
|
305
|
-
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
306
|
-
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
307
|
-
if (!token) { log(' · personal skills: no token wired — skipped'); return summary }
|
|
308
|
-
|
|
309
|
-
let cache = null
|
|
310
|
-
try { cache = JSON.parse(readFileSync(PERSONAL_CACHE, 'utf8')) } catch { /* none */ }
|
|
311
|
-
let served = null
|
|
312
|
-
try {
|
|
313
|
-
const res = await fetchCortex(`${resolveBase(process.env.CORTEX_URL)}/api/personal-skills`, {
|
|
314
|
-
headers: { Authorization: `Bearer ${token}` },
|
|
315
|
-
})
|
|
316
|
-
if (res.ok) {
|
|
317
|
-
served = (await res.json())?.skills ?? []
|
|
318
|
-
summary.source = 'server'
|
|
319
|
-
} else {
|
|
320
|
-
log(` · personal skills: server said ${res.status} — using last-good cache. Run \`doctor\` if this persists.`)
|
|
321
|
-
}
|
|
322
|
-
} catch { /* fall through to cache */ }
|
|
323
|
-
if (!served && Array.isArray(cache?.skills)) { served = cache.skills; summary.source = 'cache' }
|
|
324
|
-
if (!served) { log(' · personal skills: unreachable and no cache — skipped'); return summary }
|
|
325
|
-
|
|
326
|
-
const bundledNames = bundledSkills().map((s) => s.name)
|
|
327
|
-
const { install, skipped } = planPersonalInstall(served, bundledNames)
|
|
328
|
-
summary.skipped = skipped
|
|
329
|
-
const prevNames = Array.isArray(cache?.installed) ? cache.installed : []
|
|
330
|
-
const currentNames = install.map((s) => s.name)
|
|
331
|
-
const retiredNames = prevNames.filter((n) => !currentNames.includes(n))
|
|
332
|
-
|
|
333
|
-
// If a personal override was disabled, put back a cached org skill of that name rather than
|
|
334
|
-
// deleting it. The normal sync sequence has already refreshed ORG_CACHE before this function.
|
|
335
|
-
let orgCache = null
|
|
336
|
-
try { orgCache = JSON.parse(readFileSync(ORG_CACHE, 'utf8')) } catch { /* no org fallback */ }
|
|
337
|
-
const { install: orgInstall } = planOrgInstall(orgCache?.skills, bundledNames)
|
|
338
|
-
const orgByName = new Map(orgInstall.map((s) => [s.name, s]))
|
|
339
|
-
const fallback = retiredNames.map((name) => orgByName.get(name)).filter(Boolean)
|
|
340
|
-
const removeNames = retiredNames.filter((name) => !orgByName.has(name))
|
|
341
|
-
|
|
342
|
-
for (const cli of CLIS.filter((c) => existsSync(c.dir))) {
|
|
343
|
-
// Fallbacks first, personal second: personal > org, but bundled was installed before either.
|
|
344
|
-
const r = installInto(join(cli.dir, 'skills'), [...fallback, ...install], { removeNames })
|
|
345
|
-
summary.installed.push(...r.installed)
|
|
346
|
-
summary.repaired.push(...r.repaired)
|
|
347
|
-
summary.removed.push(...r.removed)
|
|
348
|
-
}
|
|
349
|
-
if (summary.source === 'server') {
|
|
350
|
-
ensureDir(dirname(PERSONAL_CACHE))
|
|
351
|
-
writeFileSync(PERSONAL_CACHE, JSON.stringify({ version: 1, fetchedAt: new Date().toISOString(), skills: served, installed: currentNames }, null, 2))
|
|
352
|
-
}
|
|
353
|
-
|
|
354
|
-
const changed = [...new Set([...summary.installed, ...summary.repaired])]
|
|
355
|
-
if (changed.length || summary.removed.length) {
|
|
356
|
-
const bits = []
|
|
357
|
-
if (changed.length) bits.push(`synced ${changed.join(', ')}`)
|
|
358
|
-
if (summary.removed.length) bits.push(`removed ${summary.removed.join(', ')}`)
|
|
359
|
-
process.stdout.write(`Agnoclast: personal skills — ${bits.join('; ')}${summary.source === 'cache' ? ' (offline cache)' : ''}.\n`)
|
|
360
|
-
} else if (!quiet) {
|
|
361
|
-
log(` ✓ personal skills up to date (${currentNames.length} private${skipped.length ? `, ${skipped.length} skipped` : ''})`)
|
|
362
|
-
}
|
|
363
|
-
return summary
|
|
364
|
-
}
|
|
365
|
-
|
|
366
268
|
// Render a failed push. THE SERVER ALREADY ANSWERED THE QUESTION — brain_choice_response.ts builds a
|
|
367
269
|
// body carrying a full `message` plus every brain's NAME, PAGE COUNT and SAMPLE TITLES, explicitly so
|
|
368
270
|
// the caller can choose "by what each one HOLDS, not by which name sounds related". This function
|
|
@@ -453,83 +355,10 @@ export async function runSkillsPush(argv) {
|
|
|
453
355
|
}
|
|
454
356
|
}
|
|
455
357
|
|
|
456
|
-
export function discoverSkillFiles(root) {
|
|
457
|
-
if (!root || !existsSync(root)) return []
|
|
458
|
-
const out = []
|
|
459
|
-
const walk = (dir) => {
|
|
460
|
-
for (const entry of readdirSync(dir, { withFileTypes: true })) {
|
|
461
|
-
if (!entry.isDirectory() || entry.name.startsWith('.')) continue
|
|
462
|
-
const child = join(dir, entry.name)
|
|
463
|
-
const skill = join(child, 'SKILL.md')
|
|
464
|
-
if (existsSync(skill)) out.push(skill)
|
|
465
|
-
walk(child)
|
|
466
|
-
}
|
|
467
|
-
}
|
|
468
|
-
walk(root)
|
|
469
|
-
// Prefer a direct skill over an equally named nested vendor copy. This gives a user override
|
|
470
|
-
// priority while still discovering bundled sub-skills such as gstack's long-form workflows.
|
|
471
|
-
return out.sort((a, b) => {
|
|
472
|
-
const depth = (path) => path.slice(root.length).split('/').filter(Boolean).length
|
|
473
|
-
return depth(a) - depth(b) || a.localeCompare(b)
|
|
474
|
-
})
|
|
475
|
-
}
|
|
476
|
-
|
|
477
|
-
// `skills import --from claude` is the deliberate migration command. It uploads only the user's
|
|
478
|
-
// skill bodies to their private library, then projects the served result; it never publishes to an
|
|
479
|
-
// organization and it never treats a local copy as proof that server sync worked.
|
|
480
|
-
export async function runSkillsImport(argv) {
|
|
481
|
-
if (argv.length !== 2 || argv[0] !== '--from' || argv[1] !== 'claude') {
|
|
482
|
-
process.stderr.write('Usage: skills import --from claude\n')
|
|
483
|
-
return 1
|
|
484
|
-
}
|
|
485
|
-
const { resolveBase, readWiredToken, fetchCortex } = await import('./diagnose.mjs')
|
|
486
|
-
const token = process.env.CORTEX_TOKEN || readWiredToken()
|
|
487
|
-
if (!token) { process.stderr.write('No Agnoclast token wired — run setup first.\n'); return 1 }
|
|
488
|
-
const files = discoverSkillFiles(join(homedir(), '.claude', 'skills'))
|
|
489
|
-
if (!files.length) { process.stderr.write('No Claude Code SKILL.md files found.\n'); return 1 }
|
|
490
|
-
|
|
491
|
-
const base = resolveBase(process.env.CORTEX_URL)
|
|
492
|
-
let imported = 0
|
|
493
|
-
const skipped = []
|
|
494
|
-
const seen = new Set()
|
|
495
|
-
for (const file of files) {
|
|
496
|
-
const body_md = readFileSync(file, 'utf8')
|
|
497
|
-
const name = frontmatterName(body_md, '').toLowerCase()
|
|
498
|
-
if (!NAME_RE.test(name)) { skipped.push(`${file}: invalid frontmatter name`); continue }
|
|
499
|
-
if (seen.has(name)) { skipped.push(`${file}: duplicate name ${name}`); continue }
|
|
500
|
-
seen.add(name)
|
|
501
|
-
try {
|
|
502
|
-
const res = await fetchCortex(`${base}/api/personal-skills`, {
|
|
503
|
-
method: 'POST',
|
|
504
|
-
headers: { Authorization: `Bearer ${token}`, 'Content-Type': 'application/json' },
|
|
505
|
-
body: JSON.stringify({ name, body_md, enabled: true }),
|
|
506
|
-
})
|
|
507
|
-
if (!res.ok) {
|
|
508
|
-
const response = await res.json().catch(() => ({}))
|
|
509
|
-
skipped.push(`${file}: ${response.error ?? `HTTP ${res.status}`}`)
|
|
510
|
-
continue
|
|
511
|
-
}
|
|
512
|
-
imported++
|
|
513
|
-
} catch (e) {
|
|
514
|
-
skipped.push(`${file}: network error: ${e.message}`)
|
|
515
|
-
}
|
|
516
|
-
}
|
|
517
|
-
process.stdout.write(`Agnoclast: imported ${imported}/${files.length} Claude Code skills into your private library.\n`)
|
|
518
|
-
for (const reason of skipped) process.stdout.write(` · skipped ${reason}\n`)
|
|
519
|
-
if (imported) await syncPersonalSkills({ quiet: false })
|
|
520
|
-
return skipped.length ? 1 : 0
|
|
521
|
-
}
|
|
522
|
-
|
|
523
358
|
// CLI entry: `cortex-mcp skills [--repair] [--quiet] | skills push …`. (--repair and plain install
|
|
524
359
|
// are the same idempotent operation; --repair is just the name the SessionStart hook uses for intent.)
|
|
525
360
|
export async function runSkills(argv = []) {
|
|
526
361
|
if (argv[0] === 'push') return runSkillsPush(argv.slice(1))
|
|
527
|
-
if (argv[0] === 'import') return runSkillsImport(argv.slice(1))
|
|
528
|
-
const syncing = argv[0] === 'sync'
|
|
529
|
-
if (argv[0] && !syncing && argv[0] !== '--repair' && argv[0] !== '--quiet') {
|
|
530
|
-
process.stderr.write('Usage: skills [sync|--repair] | skills import --from claude | skills push <SKILL.md> [--brain <name>]\n')
|
|
531
|
-
return 1
|
|
532
|
-
}
|
|
533
362
|
|
|
534
363
|
const quiet = argv.includes('--quiet')
|
|
535
364
|
if (!quiet) process.stdout.write('\nAgnoclast skills — installing managed skills…\n')
|
|
@@ -540,9 +369,6 @@ export async function runSkills(argv = []) {
|
|
|
540
369
|
process.stdout.write(` ✓ Up to date in ${r.targets.join(' + ')} (${[...new Set(r.unchanged)].join(', ') || 'none'}).\n`)
|
|
541
370
|
}
|
|
542
371
|
}
|
|
543
|
-
if (r.targets.length) {
|
|
544
|
-
await syncOrgSkills({ quiet })
|
|
545
|
-
await syncPersonalSkills({ quiet })
|
|
546
|
-
}
|
|
372
|
+
if (r.targets.length) await syncOrgSkills({ quiet })
|
|
547
373
|
return 0
|
|
548
374
|
}
|